From 55b0cf1d4d6ed09e275ca3488f412edd37151f6f Mon Sep 17 00:00:00 2001 From: wlix13 Date: Sat, 25 Jul 2026 23:53:31 +0300 Subject: [PATCH 1/3] build(pyproject): add tests group and pytest configuration --- pyproject.toml | 14 ++++++- uv.lock | 104 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7943dff..8eb4ebd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ scripts.nullforge = "nullforge.cli.__main__:main" [dependency-groups] dev = [ "poethepoet", "prek", "ruff", "ty" ] +tests = [ "pytest", "pytest-cov", "pytest-sugar" ] [tool.hatch] version.path = "nullforge/__init__.py" @@ -32,14 +33,25 @@ lint.ignore = [ "N818", # Domain errors are named by failure mode, not with an -Error suffix "S108", # Probable insecure usage of temporary file or directory ] +lint.per-file-ignores."tests/*" = [ + "S101", # Use of `assert` detected + "S104", # Possible binding to all interfaces + "S105", # Hardcoded password string + "S106", # Hardcoded password argument +] lint.isort.lines-after-imports = 2 [tool.ty] environment.root = [ "./nullforge" ] +[tool.pytest] +ini_options.testpaths = [ "tests" ] +ini_options.addopts = "-ra -q --tb=short --cov=nullforge --strict-markers" + [tool.poe] tasks.format = "uv run ruff format ." tasks.check-format = "uv run ruff format --check ." tasks.lint = "uv run ruff check ." -tasks.typecheck = "uv run --group dev ty check" +tasks.typecheck = "uv run --group dev --group tests ty check" tasks.check = [ "check-format", "lint", "typecheck" ] +tasks.tests = "uv run --group tests pytest" diff --git a/uv.lock b/uv.lock index d883c1e..a4a5201 100644 --- a/uv.lock +++ b/uv.lock @@ -110,6 +110,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + [[package]] name = "cryptography" version = "49.0.0" @@ -197,6 +221,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "invoke" version = "3.0.3" @@ -286,6 +319,11 @@ dev = [ { name = "ruff" }, { name = "ty" }, ] +tests = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-sugar" }, +] [package.metadata] requires-dist = [ @@ -302,6 +340,11 @@ dev = [ { name = "ruff" }, { name = "ty" }, ] +tests = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-sugar" }, +] [[package]] name = "packaging" @@ -336,6 +379,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "poethepoet" version = "0.48.0" @@ -476,6 +528,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-sugar" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -567,6 +662,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + [[package]] name = "ty" version = "0.0.63" From be72b0e7c8ae11c8414f63913b00f61cd5438dca Mon Sep 17 00:00:00 2001 From: wlix13 Date: Sat, 25 Jul 2026 23:53:31 +0300 Subject: [PATCH 2/3] test(suite): add unit tests --- tests/__init__.py | 0 tests/conftest.py | 50 ++++++ tests/test_foundry.py | 56 +++++++ tests/test_foundry_cast.py | 60 +++++++ tests/test_foundry_pyinfra.py | 94 +++++++++++ tests/test_models_dns.py | 42 +++++ tests/test_molds_dns.py | 50 ++++++ tests/test_molds_monitoring.py | 78 ++++++++++ tests/test_molds_netsec.py | 44 ++++++ tests/test_molds_telemt.py | 65 ++++++++ tests/test_molds_user.py | 21 +++ tests/test_molds_utils.py | 177 +++++++++++++++++++++ tests/test_runes_dns.py | 19 +++ tests/test_runes_meta.py | 98 ++++++++++++ tests/test_runes_misc.py | 33 ++++ tests/test_runes_netsec.py | 204 ++++++++++++++++++++++++ tests/test_runes_profiles.py | 114 ++++++++++++++ tests/test_runes_telemt.py | 105 +++++++++++++ tests/test_runes_users.py | 62 ++++++++ tests/test_smithy_arch.py | 53 +++++++ tests/test_smithy_github.py | 153 ++++++++++++++++++ tests/test_smithy_http.py | 30 ++++ tests/test_smithy_install.py | 119 ++++++++++++++ tests/test_smithy_network.py | 128 +++++++++++++++ tests/test_smithy_nezha.py | 44 ++++++ tests/test_smithy_nezha_dashboard.py | 84 ++++++++++ tests/test_smithy_packages.py | 44 ++++++ tests/test_smithy_service.py | 61 ++++++++ tests/test_smithy_sni.py | 224 +++++++++++++++++++++++++++ tests/test_smithy_swap.py | 54 +++++++ tests/test_smithy_system.py | 101 ++++++++++++ tests/test_smithy_versions.py | 169 ++++++++++++++++++++ tests/test_templates.py | 19 +++ tests/test_templates_telemt.py | 108 +++++++++++++ 34 files changed, 2763 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_foundry.py create mode 100644 tests/test_foundry_cast.py create mode 100644 tests/test_foundry_pyinfra.py create mode 100644 tests/test_models_dns.py create mode 100644 tests/test_molds_dns.py create mode 100644 tests/test_molds_monitoring.py create mode 100644 tests/test_molds_netsec.py create mode 100644 tests/test_molds_telemt.py create mode 100644 tests/test_molds_user.py create mode 100644 tests/test_molds_utils.py create mode 100644 tests/test_runes_dns.py create mode 100644 tests/test_runes_meta.py create mode 100644 tests/test_runes_misc.py create mode 100644 tests/test_runes_netsec.py create mode 100644 tests/test_runes_profiles.py create mode 100644 tests/test_runes_telemt.py create mode 100644 tests/test_runes_users.py create mode 100644 tests/test_smithy_arch.py create mode 100644 tests/test_smithy_github.py create mode 100644 tests/test_smithy_http.py create mode 100644 tests/test_smithy_install.py create mode 100644 tests/test_smithy_network.py create mode 100644 tests/test_smithy_nezha.py create mode 100644 tests/test_smithy_nezha_dashboard.py create mode 100644 tests/test_smithy_packages.py create mode 100644 tests/test_smithy_service.py create mode 100644 tests/test_smithy_sni.py create mode 100644 tests/test_smithy_swap.py create mode 100644 tests/test_smithy_system.py create mode 100644 tests/test_smithy_versions.py create mode 100644 tests/test_templates.py create mode 100644 tests/test_templates_telemt.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b32b9be --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,50 @@ +from unittest.mock import MagicMock, patch + +import pyinfra.local as _pyinfra_local +import pytest +from pyinfra.facts.server import Hostname + +from nullforge.molds.defaults import BASE_FEATURES, BASE_SYSTEM +from nullforge.molds.utils import ensure_features, ensure_system + + +_mock_host = MagicMock() +_mock_host.data.features = ensure_features(BASE_FEATURES.model_copy()) +system = ensure_system(BASE_SYSTEM.model_copy()) +system.hostname = "nullforge.test" +_mock_host.data.system = system +_mock_host.data._nullforge_supported_locales = ["en_US.UTF-8 UTF-8"] + + +def _get_fact(fact: object, *args: object, **kwargs: object) -> object: + """Return values for pyinfra facts during tests.""" + + if fact is Hostname or getattr(fact, "__name__", None) == "Hostname": + return "nullforge.test" + return MagicMock() + + +_mock_host.get_fact.side_effect = _get_fact +_mock_host.loop.side_effect = lambda iterable: iter(iterable) + +_ops = MagicMock() + +_patchers = [ + patch("pyinfra.context.host", _mock_host), + patch("pyinfra.operations.server", _ops), + patch("pyinfra.operations.systemd", _ops), + patch("pyinfra.operations.files", _ops), + patch("pyinfra.operations.git", _ops), + patch("pyinfra.operations.apt", _ops), + patch("pyinfra.operations.dnf", _ops), + patch("pyinfra.operations.python", _ops), + patch.object(_pyinfra_local, "include", MagicMock()), +] + +for _patcher in _patchers: + _patcher.start() + + +def pytest_unconfigure(config: pytest.Config) -> None: + for patcher in _patchers: + patcher.stop() diff --git a/tests/test_foundry.py b/tests/test_foundry.py new file mode 100644 index 0000000..203d556 --- /dev/null +++ b/tests/test_foundry.py @@ -0,0 +1,56 @@ +from unittest.mock import MagicMock, patch + +# conftest patches pyinfra.local so foundry can be imported without CLI mode error. +# We import cast_full at module level so the module is in sys.modules. +from nullforge.foundry.full_cast import cast_full +from nullforge.molds.defaults import BASE_FEATURES, BASE_SYSTEM +from nullforge.molds.utils import ensure_features, ensure_system + + +def test_cast_full_always_includes_base_runes() -> None: + local_mock = MagicMock() + host_mock = MagicMock() + host_mock.loop.side_effect = lambda iterable: iter(iterable) + host_mock.data.features = ensure_features(BASE_FEATURES.model_copy()) + host_mock.data.system = ensure_system(BASE_SYSTEM.model_copy()) + + with patch("nullforge.foundry.full_cast.local", local_mock): + with patch("nullforge.foundry.full_cast.host", host_mock): + cast_full() + + included = [c.args[0] for c in local_mock.include.call_args_list] + assert any("prepare.py" in p for p in included) + assert any("base.py" in p for p in included) + assert any("netsec.py" in p for p in included) + + +def test_cast_full_skips_disabled_features() -> None: + local_mock = MagicMock() + host_mock = MagicMock() + host_mock.loop.side_effect = lambda iterable: iter(iterable) + host_mock.data.features = ensure_features(BASE_FEATURES.model_copy()) + host_mock.data.system = ensure_system(BASE_SYSTEM.model_copy()) + + with patch("nullforge.foundry.full_cast.local", local_mock): + with patch("nullforge.foundry.full_cast.host", host_mock): + cast_full() + + included = [c.args[0] for c in local_mock.include.call_args_list] + assert not any("warp.py" in p for p in included) + assert not any("zerotrust.py" in p for p in included) + assert not any("haproxy.py" in p for p in included) + assert not any("containers.py" in p for p in included) + assert not any("tor.py" in p for p in included) + assert not any("xray.py" in p for p in included) + assert any("dns.py" in p for p in included) + assert any("users.py" in p for p in included) + assert any("netsec.py" in p for p in included) + assert any("profiles.py" in p for p in included) + + host_mock.data.features = ensure_features({"netsec": {"install": False}}) + local_mock.reset_mock() + with patch("nullforge.foundry.full_cast.local", local_mock): + with patch("nullforge.foundry.full_cast.host", host_mock): + cast_full() + disabled = [c.args[0] for c in local_mock.include.call_args_list] + assert not any("netsec.py" in p for p in disabled) diff --git a/tests/test_foundry_cast.py b/tests/test_foundry_cast.py new file mode 100644 index 0000000..c2ce9ee --- /dev/null +++ b/tests/test_foundry_cast.py @@ -0,0 +1,60 @@ +from unittest.mock import MagicMock, patch + +import pyinfra.context +import pytest +from pyinfra.api.exceptions import PyinfraError + +from nullforge.molds.defaults import BASE_FEATURES, BASE_SYSTEM +from nullforge.molds.utils import ensure_features, ensure_system +from nullforge.runes import rune_path + + +# cast_selected() runs at import time (pyinfra deploy-file convention); seed valid data +# on the conftest-patched host so the import-time call succeeds against the mocked include. +pyinfra.context.host.data._nullforge_runes = [str(rune_path("base"))] + +from nullforge.foundry.cast import cast_selected # noqa: E402 + + +def _make_host(runes: object) -> MagicMock: + host_mock = MagicMock() + host_mock.data.features = ensure_features(BASE_FEATURES.model_copy()) + host_mock.data.system = ensure_system(BASE_SYSTEM.model_copy()) + host_mock.data._nullforge_runes = runes + host_mock.loop.side_effect = lambda iterable: iter(iterable) + return host_mock + + +def test_cast_selected_includes_exact_paths_in_order() -> None: + local_mock = MagicMock() + paths = [str(rune_path("dns")), str(rune_path("warp")), str(rune_path("base"))] + + with patch("nullforge.foundry.cast.local", local_mock), patch("nullforge.foundry.cast.host", _make_host(paths)): + cast_selected() + + included = [call.args[0] for call in local_mock.include.call_args_list] + assert included == paths + + +@pytest.mark.parametrize("invalid", [None, [], "warp", [1, 2], [str(rune_path("base")), 3]]) +def test_cast_selected_rejects_invalid_rune_data(invalid: object) -> None: + local_mock = MagicMock() + + with patch("nullforge.foundry.cast.local", local_mock), patch("nullforge.foundry.cast.host", _make_host(invalid)): + with pytest.raises(PyinfraError): + cast_selected() + + local_mock.include.assert_not_called() + + +def test_cast_selected_coerces_molds() -> None: + local_mock = MagicMock() + host_mock = _make_host([str(rune_path("base"))]) + host_mock.data.features = None + host_mock.data.system = None + + with patch("nullforge.foundry.cast.local", local_mock), patch("nullforge.foundry.cast.host", host_mock): + cast_selected() + + assert host_mock.data.features == ensure_features(None) + assert host_mock.data.system == ensure_system(None) diff --git a/tests/test_foundry_pyinfra.py b/tests/test_foundry_pyinfra.py new file mode 100644 index 0000000..2171398 --- /dev/null +++ b/tests/test_foundry_pyinfra.py @@ -0,0 +1,94 @@ +from collections.abc import Iterator + +import pytest +from pyinfra.api import host as host_module +from pyinfra_cli import cli as cli_module +from pyinfra_cli import util as util_module + +from nullforge.foundry import _pyinfra + + +class TestStem: + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("/home/x/runes/base.py", "base"), + ("full_cast.py", "full_cast"), + ("../runes/dns.py", "dns"), + ("@local", "@local"), + ("", ""), + (None, None), + ], + ) + def test_stem(self, value: str | None, expected: str | None) -> None: + assert _pyinfra._stem(value) == expected + + +@pytest.fixture +def restore_pyinfra() -> Iterator[None]: + """Snapshot Pyinfra functions patches mutate and restore them.""" + + saved_deploy = host_module.Host.deploy + saved_ready = util_module._parallel_load_hosts + saved_loading = cli_module._prepare_deploy_operations + saved_patched = _pyinfra._patched.copy() + try: + yield + finally: + host_module.Host.deploy = saved_deploy + util_module._parallel_load_hosts = saved_ready + cli_module._prepare_deploy_operations = saved_loading + _pyinfra._patched.clear() + _pyinfra._patched.update(saved_patched) + + +class TestApplyPatches: + def test_wraps_all_three_functions(self, restore_pyinfra: None) -> None: + + before = ( + host_module.Host.deploy, + util_module._parallel_load_hosts, + cli_module._prepare_deploy_operations, + ) + _pyinfra.apply_patches() + after = ( + host_module.Host.deploy, + util_module._parallel_load_hosts, + cli_module._prepare_deploy_operations, + ) + + assert all(b is not a for b, a in zip(before, after)), "all three functions must be wrapped" + + def test_is_idempotent(self, restore_pyinfra: None) -> None: + + _pyinfra.apply_patches() + first = host_module.Host.deploy + _pyinfra.apply_patches() + + assert host_module.Host.deploy is first, "second apply_patches must not re-wrap" + + +class TestLoadingMessage: + def test_shortens_log_but_loads_real_path(self, restore_pyinfra: None, monkeypatch: pytest.MonkeyPatch) -> None: + + logged: list[str] = [] + loaded: list[str] = [] + + _pyinfra._patch_loading_message() + monkeypatch.setattr(cli_module.logger, "info", lambda msg: logged.append(msg)) + monkeypatch.setattr(cli_module, "load_deploy_file", lambda state, filename: loaded.append(filename)) + + class Config: + def lock_current_state(self) -> None: ... + def reset_locked_state(self) -> None: ... + + class State: ... + + cli_module._prepare_deploy_operations( + State(), + Config(), + ["/home/x/foundry/full_cast.py"], + ) + + assert any("full_cast" in line and "full_cast.py" not in line for line in logged), logged + assert loaded == ["/home/x/foundry/full_cast.py"], "the real path must still be loaded" diff --git a/tests/test_models_dns.py b/tests/test_models_dns.py new file mode 100644 index 0000000..ca206e4 --- /dev/null +++ b/tests/test_models_dns.py @@ -0,0 +1,42 @@ +import pytest + +from nullforge.models.dns import DnsProtocol, DnsProvider, DnsProviders, DnsServerDoH, DnsServerDoT + + +class TestGetUpstreams: + def test_cloudflare_doh_ipv4(self) -> None: + servers = DnsProviders.get_upstreams(DnsProvider.CLOUDFLARE, DnsProtocol.DOH, ipv6=False) + assert len(servers) == 2 + assert all(isinstance(s, DnsServerDoH) for s in servers) + + def test_cloudflare_dot(self) -> None: + servers = DnsProviders.get_upstreams(DnsProvider.CLOUDFLARE, DnsProtocol.DOT, ipv6=False) + assert len(servers) == 2 + assert all(isinstance(s, DnsServerDoT) for s in servers) + dot_servers = [s for s in servers if isinstance(s, DnsServerDoT)] + assert all(s.sni == "cloudflare-dns.com" for s in dot_servers) + + def test_quad9_doh_no_ecs(self) -> None: + servers = DnsProviders.get_upstreams(DnsProvider.QUAD9, DnsProtocol.DOH, ipv6=False, ecs=False) + doh = [s for s in servers if isinstance(s, DnsServerDoH)] + assert len(doh) == 2 + assert all("9.9.9.10" in s.url or "149.112.112.10" in s.url for s in doh) + + def test_quad9_doh_with_ecs(self) -> None: + servers = DnsProviders.get_upstreams(DnsProvider.QUAD9, DnsProtocol.DOH, ipv6=False, ecs=True) + doh = [s for s in servers if isinstance(s, DnsServerDoH)] + assert len(doh) == 2 + assert all("9.9.9.12" in s.url or "149.112.112.12" in s.url for s in doh) + + def test_ecs_raises_for_non_quad9(self) -> None: + with pytest.raises(ValueError, match="ECS is supported only"): + DnsProviders.get_upstreams(DnsProvider.CLOUDFLARE, DnsProtocol.DOH, ipv6=False, ecs=True) + + def test_unsupported_protocol_raises(self) -> None: + with pytest.raises(ValueError): + DnsProviders.get_upstreams(DnsProvider.CLOUDFLARE, DnsProtocol.DOU, ipv6=False) + + def test_ipv6_doubles_server_count(self) -> None: + v4 = DnsProviders.get_upstreams(DnsProvider.CLOUDFLARE, DnsProtocol.DOH, ipv6=False) + v6 = DnsProviders.get_upstreams(DnsProvider.CLOUDFLARE, DnsProtocol.DOH, ipv6=True) + assert len(v6) == len(v4) * 2 diff --git a/tests/test_molds_dns.py b/tests/test_molds_dns.py new file mode 100644 index 0000000..0f763f9 --- /dev/null +++ b/tests/test_molds_dns.py @@ -0,0 +1,50 @@ +from ipaddress import IPv4Address, IPv6Address + +import pytest + +from nullforge.molds import DnsMold + + +class TestDnsMoldListenAddress: + @pytest.mark.parametrize( + "addr", + [ + "0.0.0.0", + "::", + "127.0.0.1", + "::1", + ], + ) + def test_not_need_custom_interface(self, addr: str) -> None: + mold = DnsMold(listen_address=addr) + assert isinstance(mold.listen_address, IPv4Address | IPv6Address) + assert mold.needs_custom_interface is False + + @pytest.mark.parametrize( + "addr", + [ + "169.254.0.53", + "10.0.0.53", + "192.0.2.1", + "2001:db8::1", + "fd00::53", + "fe80::53", + ], + ) + def test_need_custom_interface(self, addr: str) -> None: + mold = DnsMold(listen_address=addr) + assert isinstance(mold.listen_address, IPv4Address | IPv6Address) + assert mold.needs_custom_interface is True + + @pytest.mark.parametrize( + "addr", + [ + "8.8.8.8", + "1.1.1.1", + "2001:4860:4860::8888", + "2606:4700:4700::1111", + ], + ) + def test_public_addresses_rejected(self, addr: str) -> None: + with pytest.raises(ValueError): + DnsMold(listen_address=addr) diff --git a/tests/test_molds_monitoring.py b/tests/test_molds_monitoring.py new file mode 100644 index 0000000..792f85e --- /dev/null +++ b/tests/test_molds_monitoring.py @@ -0,0 +1,78 @@ +import uuid +from typing import Any + +import pytest +from pydantic import ValidationError + +from nullforge.molds.monitoring import MonitoringMold, NezhaBackend + + +def _valid(**overrides: object) -> dict[str, Any]: + backend: dict[str, object] = { + "type": "nezha", + "server": "agent.status.example.com:443", + "client_secret": "secret", + "dashboard_url": "https://dash.example.com", + "api_token": "nzp_token", + } + backend.update(overrides) + return {"install": True, "backend": backend} + + +class TestValidation: + def test_install_requires_server(self) -> None: + with pytest.raises(ValidationError, match="server is required"): + MonitoringMold(**_valid(server="")) + + def test_install_requires_secret(self) -> None: + with pytest.raises(ValidationError, match="client_secret is required"): + MonitoringMold(**_valid(client_secret="")) + + def test_rename_requires_dashboard_and_token(self) -> None: + with pytest.raises(ValidationError, match="dashboard_url and api_token"): + MonitoringMold(**_valid(dashboard_url="")) + + def test_rename_disabled_relaxes_requirements(self) -> None: + mold = MonitoringMold( + install=True, + backend=NezhaBackend(server="x:443", client_secret="s", set_name_to_hostname=False), + ) + assert mold.backend.set_name_to_hostname is False + + def test_dashboard_url_scheme_enforced(self) -> None: + with pytest.raises(ValidationError, match="http"): + MonitoringMold(**_valid(dashboard_url="dash.example.com")) + + def test_dashboard_url_trailing_slash_stripped(self) -> None: + mold = MonitoringMold(**_valid(dashboard_url="https://dash.example.com/")) + assert mold.backend.dashboard_url == "https://dash.example.com" + + def test_uuid_namespace_accepts_uuid_string(self) -> None: + ns = uuid.uuid4() + backend = NezhaBackend(uuid_namespace=str(ns)) + assert backend.uuid_namespace == ns + + def test_uuid_namespace_derives_from_freeform_string(self) -> None: + backend = NezhaBackend(uuid_namespace="not-a-uuid") + assert backend.uuid_namespace == uuid.uuid5(uuid.UUID(int=0), "not-a-uuid") + + def test_uuid_namespace_empty_string_is_none(self) -> None: + assert NezhaBackend(uuid_namespace="").uuid_namespace is None + + def test_disabled_install_skips_validation(self) -> None: + # install=False should not require any connection fields + assert MonitoringMold(install=False).install is False + + +class TestRedaction: + def test_to_json_masks_secrets(self) -> None: + mold = MonitoringMold(**_valid()) + backend = mold.to_json()["backend"] + assert backend["client_secret"] == "***" + assert backend["api_token"] == "***" + assert backend["server"] == "agent.status.example.com:443" + + def test_model_dump_keeps_secrets_for_deploy(self) -> None: + # the merge pipeline relies on real values via model_dump, not to_json + mold = MonitoringMold(**_valid()) + assert mold.model_dump()["backend"]["client_secret"] == "secret" diff --git a/tests/test_molds_netsec.py b/tests/test_molds_netsec.py new file mode 100644 index 0000000..87403e4 --- /dev/null +++ b/tests/test_molds_netsec.py @@ -0,0 +1,44 @@ +import pytest +from pydantic import ValidationError + +from nullforge.models.netsec import SshHostKeyType +from nullforge.molds.netsec import FirewallRule, SshMold + + +class TestFirewallRuleIsIpv6: + def _rule(self, from_ip: str | None = None, to_ip: str | None = None) -> FirewallRule: + return FirewallRule(from_ip=from_ip, to_ip=to_ip) + + def test_no_ips_is_not_ipv6(self) -> None: + assert self._rule().is_ipv6 is False + + def test_ipv4_from_is_not_ipv6(self) -> None: + assert self._rule(from_ip="192.168.1.1").is_ipv6 is False + + def test_ipv6_from_is_ipv6(self) -> None: + assert self._rule(from_ip="2001:db8::1").is_ipv6 is True + + def test_ipv6_cidr_is_ipv6(self) -> None: + assert self._rule(from_ip="2001:db8::/32").is_ipv6 is True + + def test_mixed_ipv4_ipv6_is_not_ipv6(self) -> None: + assert self._rule(from_ip="192.168.1.1", to_ip="2001:db8::1").is_ipv6 is False + + def test_both_ipv6_is_ipv6(self) -> None: + assert self._rule(from_ip="2001:db8::1", to_ip="2001:db8::2").is_ipv6 is True + + def test_any_keyword_is_not_ipv6(self) -> None: + assert self._rule(from_ip="any").is_ipv6 is False + + def test_invalid_ip_returns_false(self) -> None: + assert self._rule(from_ip="not-an-ip").is_ipv6 is False + + +class TestSshMold: + def test_empty_host_keys_rejected(self) -> None: + with pytest.raises(ValidationError, match="at least one host key"): + SshMold(host_keys=[]) + + def test_host_keys_coerce_and_dedupe(self) -> None: + mold = SshMold.model_validate({"host_keys": ["ed25519", "rsa", "ed25519"]}) + assert mold.host_keys == [SshHostKeyType.ED25519, SshHostKeyType.RSA] diff --git a/tests/test_molds_telemt.py b/tests/test_molds_telemt.py new file mode 100644 index 0000000..b5a7c6e --- /dev/null +++ b/tests/test_molds_telemt.py @@ -0,0 +1,65 @@ +import pytest +from pydantic import ValidationError + +from nullforge.molds import TelemtMold + + +VALID_SECRET = "bf777cca8384a074a671460d51e4e31f" + + +class TestEnabledValidation: + def test_install_requires_users(self) -> None: + with pytest.raises(ValidationError, match="at least one entry in `users`"): + TelemtMold(install=True, tls_domain="example.com") + + def test_install_tls_requires_domain(self) -> None: + with pytest.raises(ValidationError, match="tls_domain` is required"): + TelemtMold(install=True, users={"a": VALID_SECRET}) + + def test_install_requires_a_mode(self) -> None: + with pytest.raises(ValidationError, match="at least one of"): + TelemtMold( + install=True, + tls_domain="example.com", + users={"a": VALID_SECRET}, + mode_tls=False, + ) + + def test_secure_mode_without_domain_ok(self) -> None: + mold = TelemtMold( + install=True, + users={"a": VALID_SECRET}, + mode_tls=False, + mode_secure=True, + ) + assert mold.is_active is True + + def test_disabled_skips_validation(self) -> None: + # An off feature must never raise even when otherwise incomplete. + assert TelemtMold(install=False).is_active is False + + +class TestUserValidation: + def test_uppercase_secret_normalized(self) -> None: + mold = TelemtMold(install=True, tls_domain="d", users={"a": VALID_SECRET.upper()}) + assert mold.users["a"] == VALID_SECRET + + def test_secret_wrong_length_rejected(self) -> None: + with pytest.raises(ValidationError, match="32 hexadecimal"): + TelemtMold(install=True, tls_domain="d", users={"a": "deadbeef"}) + + def test_secret_non_hex_rejected(self) -> None: + with pytest.raises(ValidationError, match="32 hexadecimal"): + TelemtMold(install=True, tls_domain="d", users={"a": "z" * 32}) + + def test_username_bad_chars_rejected(self) -> None: + with pytest.raises(ValidationError, match="Invalid telemt username"): + TelemtMold(install=True, tls_domain="d", users={"bad name": VALID_SECRET}) + + +class TestRedaction: + def test_users_redacted_in_json(self) -> None: + mold = TelemtMold(install=True, tls_domain="example.com", users={"a": VALID_SECRET}) + dumped = mold.to_json() + assert dumped["users"] == "***" + assert VALID_SECRET not in str(dumped) diff --git a/tests/test_molds_user.py b/tests/test_molds_user.py new file mode 100644 index 0000000..73ec954 --- /dev/null +++ b/tests/test_molds_user.py @@ -0,0 +1,21 @@ +import pytest +from pydantic import ValidationError + +from nullforge.molds.user import UserMold + + +class TestFetchKeyFromGithub: + @pytest.mark.parametrize( + "username", + ["octocat", "mojombo", "a"], + ) + def test_accepts_valid_username(self, username: str) -> None: + assert UserMold(fetch_key_from_github=username).fetch_key_from_github == username + + @pytest.mark.parametrize( + "username", + ["", "-octocat", "user_name", "user.name", "with space", "a" * 40], + ) + def test_rejects_invalid_username(self, username: str) -> None: + with pytest.raises(ValidationError): + UserMold(fetch_key_from_github=username) diff --git a/tests/test_molds_utils.py b/tests/test_molds_utils.py new file mode 100644 index 0000000..0ff3ef4 --- /dev/null +++ b/tests/test_molds_utils.py @@ -0,0 +1,177 @@ +import pytest + +from nullforge.models.dns import DnsMode +from nullforge.molds import DnsMold, FeaturesMold, NetSecMold, ProfilesMold, SystemMold, UserMold, WarpMold +from nullforge.molds.features import iter_runes +from nullforge.molds.utils import ( + _deep_merge_dicts, + ensure_features, + ensure_system, + merge_features, + merge_system, +) + + +class TestDeepMergeDicts: + def test_simple_override(self) -> None: + assert _deep_merge_dicts({"a": 1}, {"a": 2}) == {"a": 2} + + def test_new_key(self) -> None: + assert _deep_merge_dicts({"a": 1}, {"b": 2}) == {"a": 1, "b": 2} + + def test_nested_merge(self) -> None: + a = {"x": {"a": 1, "b": 2}} + b = {"x": {"b": 3, "c": 4}} + assert _deep_merge_dicts(a, b) == {"x": {"a": 1, "b": 3, "c": 4}} + + def test_none_in_b_overwrites(self) -> None: + assert _deep_merge_dicts({"a": 1}, {"a": None}) == {"a": None} + + def test_does_not_mutate_a(self) -> None: + a = {"x": {"y": 1}} + _deep_merge_dicts(a, {"x": {"z": 2}}) + assert a == {"x": {"y": 1}} + + def test_empty_b(self) -> None: + assert _deep_merge_dicts({"a": 1}, {}) == {"a": 1} + + def test_empty_a(self) -> None: + assert _deep_merge_dicts({}, {"a": 1}) == {"a": 1} + + +class TestMergeFeatures: + def test_none_layer_is_ignored(self) -> None: + base = FeaturesMold() + result = merge_features(base, None) + assert result == base + + def test_sub_mold_overrides_field(self) -> None: + base = FeaturesMold() + dns = DnsMold(mode=DnsMode.NONE) + result = merge_features(base, dns) + assert result.dns.mode == DnsMode.NONE + + def test_multiple_layers_applied_in_order(self) -> None: + base = FeaturesMold() + dns1 = DnsMold(mode=DnsMode.BLOCKY) + dns2 = DnsMold(mode=DnsMode.DOT_RESOLVED) + result = merge_features(base, dns1, dns2) + assert result.dns.mode == DnsMode.DOT_RESOLVED + + def test_unsupported_type_raises(self) -> None: + base = FeaturesMold() + with pytest.raises(TypeError): + merge_features(base, 42) + + def test_non_feature_base_mold_raises(self) -> None: + base = FeaturesMold() + with pytest.raises(TypeError, match="Unsupported features layer type"): + merge_features(base, SystemMold()) + + +class TestEnsureFeatures: + def test_none_returns_default(self) -> None: + assert isinstance(ensure_features(None), FeaturesMold) + + def test_passthrough_features_mold(self) -> None: + f = FeaturesMold() + assert ensure_features(f) is f + + def test_dict_coerced(self) -> None: + result = ensure_features({}) + assert isinstance(result, FeaturesMold) + + def test_unsupported_type_raises(self) -> None: + with pytest.raises(TypeError): + ensure_features(123) + + +class TestMergeSystem: + def test_none_layer_is_ignored(self) -> None: + base = SystemMold() + assert merge_system(base, None) == base + + def test_dict_layer_overrides(self) -> None: + base = SystemMold() + result = merge_system(base, {"hostname": "myserver.example.com"}) + assert result.hostname == "myserver.example.com" + + def test_system_mold_layer(self) -> None: + base = SystemMold() + overlay = SystemMold(hostname="overlay.example.com") + result = merge_system(base, overlay) + assert result.hostname == "overlay.example.com" + + +class TestEnsureSystem: + def test_none_returns_default(self) -> None: + assert isinstance(ensure_system(None), SystemMold) + + def test_passthrough_system_mold(self) -> None: + s = SystemMold() + assert ensure_system(s) is s + + def test_dict_coerced(self) -> None: + result = ensure_system({}) + assert isinstance(result, SystemMold) + + def test_unsupported_type_raises(self) -> None: + with pytest.raises(TypeError): + ensure_system(42) + + +def _active(features: FeaturesMold) -> list[str]: + return [name for name, active in iter_runes(features) if active] + + +class TestIterRunes: + def test_order_and_membership_do_not_depend_on_activation(self) -> None: + bare = [name for name, _ in iter_runes(FeaturesMold())] + loaded = [ + name + for name, _ in iter_runes( + FeaturesMold( + warp=WarpMold(install=True), + ), + ) + ] + + assert bare == loaded + assert len(bare) == len(set(bare)) + + def test_defaults_activate_dns_users_netsec_profiles(self) -> None: + f = FeaturesMold() + names = _active(f) + assert "dns" in names + assert "users" in names + assert "netsec" in names + assert "profiles" in names + # install=False by default are inactive + assert "warp" not in names + assert "monitoring" not in names + + def test_dns_none_deactivates(self) -> None: + f = FeaturesMold(dns=DnsMold(mode=DnsMode.NONE)) + assert "dns" not in _active(f) + + def test_profiles_compound_activation(self) -> None: + f = FeaturesMold(profiles=ProfilesMold(for_root=False, for_user=False)) + assert "profiles" not in _active(f) + + f2 = FeaturesMold(profiles=ProfilesMold(for_root=False, for_user=True)) + assert "profiles" in _active(f2) + + def test_users_manage_flag(self) -> None: + f = FeaturesMold(users=UserMold(manage=False)) + assert "users" not in _active(f) + + def test_active_install_flag(self) -> None: + f = FeaturesMold(warp=WarpMold(install=True)) + assert "warp" in _active(f) + + def test_netsec_can_be_disabled(self) -> None: + f = FeaturesMold(netsec=NetSecMold(install=False)) + assert "netsec" not in _active(f) + + f2 = FeaturesMold() # default install=True + assert "netsec" in _active(f2) diff --git a/tests/test_runes_dns.py b/tests/test_runes_dns.py new file mode 100644 index 0000000..9473baf --- /dev/null +++ b/tests/test_runes_dns.py @@ -0,0 +1,19 @@ +from nullforge.models.dns import DnsServerDoH, DnsServerDoT +from nullforge.runes.dns import _format_blocky_upstreams + + +class TestFormatBlockyUpstreams: + def test_doh_server(self) -> None: + srv = DnsServerDoH(url="https://1.1.1.1/dns-query") + result = _format_blocky_upstreams([srv]) + assert result == ["https://1.1.1.1/dns-query"] + + def test_dot_server_ipv4(self) -> None: + srv = DnsServerDoT(host="1.1.1.1", sni="cloudflare-dns.com") + result = _format_blocky_upstreams([srv]) + assert result == ["tcp-tls:1.1.1.1:853"] + + def test_dot_server_ipv6_brackets(self) -> None: + srv = DnsServerDoT(host="2606:4700:4700::1111", port=853) + result = _format_blocky_upstreams([srv]) + assert result == ["tcp-tls:[2606:4700:4700::1111]:853"] diff --git a/tests/test_runes_meta.py b/tests/test_runes_meta.py new file mode 100644 index 0000000..32cc26c --- /dev/null +++ b/tests/test_runes_meta.py @@ -0,0 +1,98 @@ +import ast +from pathlib import Path + +import pytest + +from nullforge.runes import RUNES_DIR, discover_runes, rune_summary + + +FOUNDRY_DIR = RUNES_DIR.parent / "foundry" + +OP_NAMESPACES = frozenset({"files", "server", "systemd", "git", "apt", "dnf", "python", "local", "pm"}) +"""Names operations are called on; a call on any of these adds a node to pyinfra's ordering DAG.""" + + +def test_discover_runes_skips_private_and_sorts(tmp_path: Path) -> None: + (tmp_path / "_private.py").write_text('"""Private."""\n') + (tmp_path / "beta.py").write_text('"""Beta rune.\n\nLonger description."""\n') + (tmp_path / "alpha.py").write_text('"""Alpha rune."""\n') + + infos = discover_runes(tmp_path) + + assert [info.name for info in infos] == ["alpha", "beta"] + assert infos[0].summary == "Alpha rune." + assert infos[1].summary == "Beta rune." + + +def test_discover_runes_missing_directory(tmp_path: Path) -> None: + assert discover_runes(tmp_path / "gone") == () + + +def test_rune_summary_never_imports_the_module(tmp_path: Path) -> None: + rune = tmp_path / "explosive.py" + rune.write_text('"""Explosive rune."""\n\nraise AssertionError("executed")\n') + + assert rune_summary(rune) == "Explosive rune." + + +def test_rune_summary_handles_missing_docstring_and_file(tmp_path: Path) -> None: + bare = tmp_path / "bare.py" + bare.write_text("x = 1\n") + + assert rune_summary(bare) == "" + assert rune_summary(tmp_path / "gone.py") == "" + + +def _emits_operations(node: ast.AST, emitting_functions: set[str]) -> bool: + """Whether this subtree calls a pyinfra operation, directly or via a function in the same module.""" + + for child in ast.walk(node): + if not isinstance(child, ast.Call): + continue + func = child.func + if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name) and func.value.id in OP_NAMESPACES: + return True + if isinstance(func, ast.Name) and func.id in emitting_functions: + return True + return False + + +def _emitting_functions(tree: ast.Module) -> set[str]: + """Names of module-level functions that emit operations, resolved transitively.""" + + functions = {node.name: node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)} + emitting: set[str] = set() + + while True: + found = {name for name, node in functions.items() if name not in emitting and _emits_operations(node, emitting)} + if not found: + return emitting + emitting |= found + + +def _is_host_loop(node: ast.expr) -> bool: + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "loop" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "host" + ) + + +@pytest.mark.parametrize( + "module_path", + sorted(RUNES_DIR.glob("*.py")) + sorted(FOUNDRY_DIR.glob("*.py")), + ids=lambda path: f"{path.parent.name}/{path.name}", +) +def test_operation_loops_use_host_loop(module_path: Path) -> None: + tree = ast.parse(module_path.read_text(encoding="utf-8")) + emitting_functions = _emitting_functions(tree) + + offenders = [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.For) and _emits_operations(node, emitting_functions) and not _is_host_loop(node.iter) + ] + + assert not offenders, f"{module_path.name} emits operations in a plain loop at line(s) {offenders}; use host.loop()" diff --git a/tests/test_runes_misc.py b/tests/test_runes_misc.py new file mode 100644 index 0000000..0f28ee0 --- /dev/null +++ b/tests/test_runes_misc.py @@ -0,0 +1,33 @@ +import importlib + +import pytest + + +RUNE_MODULES = [ + "nullforge.runes.base", + "nullforge.runes.containers", + "nullforge.runes.haproxy", + "nullforge.runes.monitoring", + "nullforge.runes.prepare", + "nullforge.runes.profiles", + "nullforge.runes.telemt", + "nullforge.runes.tor", + "nullforge.runes.users", + "nullforge.runes.warp", + "nullforge.runes.xray", + "nullforge.runes.zerotrust", +] + + +@pytest.mark.parametrize("module_name", RUNE_MODULES) +def test_rune_module_exposes_deploy_entrypoint(module_name: str) -> None: + module = importlib.import_module(module_name) + deploy_fns = [ + name + for name in dir(module) + if name.startswith("deploy_") + and callable( + getattr(module, name), + ) + ] + assert deploy_fns, f"{module_name} exposes no deploy_* entrypoint" diff --git a/tests/test_runes_netsec.py b/tests/test_runes_netsec.py new file mode 100644 index 0000000..5a2c89e --- /dev/null +++ b/tests/test_runes_netsec.py @@ -0,0 +1,204 @@ +from unittest.mock import patch + +import pytest + +from nullforge.models.netsec import ( + CLASSICAL_KEX_ALGORITHMS, + PQ_KEX_ALGORITHMS, + WEAK_KEX_PATTERNS, + WEAK_MAC_PATTERNS, + SshHostKeyType, +) +from nullforge.molds.netsec import FirewallRule, SshMold +from nullforge.runes import netsec as netsec_rune +from nullforge.runes.netsec import ( + _build_firewalld_commands, + _build_sshd_dropin, + _build_ufw_command, + _resolve_conntrack_sysctls, + _supported_kex_algorithms, +) +from nullforge.smithy.network import conntrack_max_for + + +class TestBuildUfwCommand: + def _rule(self, **kwargs) -> FirewallRule: + return FirewallRule(**kwargs) + + def test_basic_allow(self) -> None: + cmd = _build_ufw_command(self._rule(port=22, action="allow")) + assert "ufw allow" in cmd + assert "port 22" in cmd + + def test_deny_with_proto(self) -> None: + cmd = _build_ufw_command(self._rule(port=80, action="deny", proto="tcp")) + assert "deny" in cmd + assert "proto tcp" in cmd + + def test_from_ip(self) -> None: + cmd = _build_ufw_command(self._rule(from_ip="10.0.0.0/8", action="allow")) + assert "from 10.0.0.0/8" in cmd + + def test_any_proto_omitted(self) -> None: + cmd = _build_ufw_command(self._rule(port=22, proto="any")) + assert "proto" not in cmd + + def test_comment_included(self) -> None: + cmd = _build_ufw_command(self._rule(port=22, comment="SSH")) + assert '"SSH"' in cmd + + def test_interface_included(self) -> None: + cmd = _build_ufw_command(self._rule(port=443, interface="eth0")) + assert "on eth0" in cmd + + +class TestBuildFirewalldCommands: + def _rule(self, **kwargs) -> FirewallRule: + return FirewallRule(**kwargs) + + def test_simple_allow_port_tcp(self) -> None: + cmds = _build_firewalld_commands(self._rule(port=80, proto="tcp", action="allow")) + assert len(cmds) == 1 + assert "--add-port=80/tcp" in cmds[0] + + def test_simple_allow_port_any_generates_two(self) -> None: + cmds = _build_firewalld_commands(self._rule(port=443, proto="any", action="allow")) + assert len(cmds) == 2 + assert any("tcp" in c for c in cmds) + assert any("udp" in c for c in cmds) + + def test_rich_rule_with_source(self) -> None: + cmds = _build_firewalld_commands(self._rule(port=22, proto="tcp", from_ip="10.0.0.0/8", action="allow")) + assert len(cmds) == 1 + assert "add-rich-rule" in cmds[0] + assert "source" in cmds[0] + + def test_deny_action(self) -> None: + cmds = _build_firewalld_commands(self._rule(port=22, action="deny", from_ip="1.2.3.4")) + assert "drop" in cmds[0] + + def test_out_direction_raises(self) -> None: + with pytest.raises(ValueError, match="direction='out'"): + _build_firewalld_commands(self._rule(port=22, direction="out")) + + def test_limit_action_raises(self) -> None: + with pytest.raises(ValueError, match="action='limit'"): + _build_firewalld_commands(self._rule(port=22, action="limit")) + + def test_no_port_no_proto_in_rich_rule(self) -> None: + cmds = _build_firewalld_commands(self._rule(from_ip="10.0.0.0/8", action="deny")) + assert "port" not in cmds[0] + + +class TestResolveConntrackSysctls: + MAX_KEY = "net.netfilter.nf_conntrack_max" + TOTAL_MEMORY = 2048 + + def test_none_passes_through(self) -> None: + assert _resolve_conntrack_sysctls(None) is None + + def test_empty_passes_through(self) -> None: + assert _resolve_conntrack_sysctls({}) == {} + + def test_skipped_when_module_not_loaded(self) -> None: + with patch("nullforge.runes.netsec.module_loaded", return_value=False): + assert _resolve_conntrack_sysctls({"net.netfilter.nf_conntrack_udp_timeout": 30}) is None + + def test_injects_ram_derived_sizing(self) -> None: + with ( + patch("nullforge.runes.netsec.module_loaded", return_value=True), + patch("nullforge.runes.netsec.get_total_memory", return_value=self.TOTAL_MEMORY), + ): + result = _resolve_conntrack_sysctls({"net.netfilter.nf_conntrack_udp_timeout": 30}) + + ct_max_target = conntrack_max_for(self.TOTAL_MEMORY) + ct_buckets = max(4096, (ct_max_target + 3) // 4) + assert result is not None + assert result[self.MAX_KEY] == ct_buckets * 4 + assert ct_max_target <= result[self.MAX_KEY] <= ct_max_target + 3 + # configured timeouts are preserved alongside the injected sizing + assert result["net.netfilter.nf_conntrack_udp_timeout"] == 30 + + def test_explicit_max_overrides_runtime_value(self) -> None: + with ( + patch("nullforge.runes.netsec.module_loaded", return_value=True), + patch("nullforge.runes.netsec.get_total_memory", return_value=self.TOTAL_MEMORY), + ): + result = _resolve_conntrack_sysctls({self.MAX_KEY: 99}) + + assert result is not None + assert result[self.MAX_KEY] == 99 + + +class TestBuildSshdDropin: + ALL_SUPPORTED = frozenset(PQ_KEX_ALGORITHMS + CLASSICAL_KEX_ALGORITHMS) + + def test_everything_off_renders_nothing(self) -> None: + mold = SshMold(pq_kex_priority=False, strip_weak_algorithms=False) + assert _build_sshd_dropin(mold, self.ALL_SUPPORTED) == "" + + def test_pq_only_filters_unsupported_algorithms(self) -> None: + supported = frozenset({"sntrup761x25519-sha512@openssh.com", "curve25519-sha256"}) + content = _build_sshd_dropin(SshMold(strip_weak_algorithms=False), supported) + assert "KexAlgorithms ^sntrup761x25519-sha512@openssh.com" in content + assert "mlkem768" not in content + + def test_pq_only_without_support_renders_nothing(self) -> None: + assert _build_sshd_dropin(SshMold(strip_weak_algorithms=False), frozenset()) == "" + + def test_strip_allowlist_filters_unsupported(self) -> None: + supported = frozenset({"mlkem768x25519-sha256", "curve25519-sha256"}) + content = _build_sshd_dropin(SshMold(), supported) + assert "KexAlgorithms mlkem768x25519-sha256,curve25519-sha256\n" in content + + def test_strip_without_pq_support_falls_back_to_removal_patterns(self) -> None: + content = _build_sshd_dropin(SshMold(), frozenset()) + assert f"KexAlgorithms -{WEAK_KEX_PATTERNS}" in content + + def test_strip_with_pq_disabled_uses_removal_patterns(self) -> None: + content = _build_sshd_dropin(SshMold(pq_kex_priority=False), self.ALL_SUPPORTED) + assert f"KexAlgorithms -{WEAK_KEX_PATTERNS}" in content + + def test_strip_adds_mac_and_ca_directives(self) -> None: + content = _build_sshd_dropin(SshMold(), self.ALL_SUPPORTED) + assert f"MACs -{WEAK_MAC_PATTERNS}" in content + assert "CASignatureAlgorithms -ssh-rsa" in content + + def test_no_strip_omits_mac_and_ca_directives(self) -> None: + content = _build_sshd_dropin(SshMold(strip_weak_algorithms=False), self.ALL_SUPPORTED) + assert "MACs" not in content + assert "CASignatureAlgorithms" not in content + + def test_host_keys_render_hostkey_lines_and_explicit_algorithms(self) -> None: + mold = SshMold( + host_keys=[SshHostKeyType.ED25519, SshHostKeyType.RSA], + pq_kex_priority=False, + strip_weak_algorithms=False, + ) + content = _build_sshd_dropin(mold, frozenset()) + assert content.splitlines()[1:] == [ + "HostKey /etc/ssh/ssh_host_ed25519_key", + "HostKey /etc/ssh/ssh_host_rsa_key", + "HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256", + ] + assert "ecdsa" not in content + + +class TestFilterDhModuli: + def test_skips_when_no_weak_moduli(self) -> None: + with patch.object(netsec_rune.host, "get_fact", return_value=""): + assert netsec_rune._filter_dh_moduli() is None + + def test_filters_when_weak_moduli_present(self) -> None: + with patch.object(netsec_rune.host, "get_fact", return_value="20250101 2 6 100 2047 2 c0ffee"): + assert netsec_rune._filter_dh_moduli() is not None + + +class TestSupportedKexAlgorithms: + def test_parses_and_strips_command_output(self) -> None: + with patch.object(netsec_rune.host, "get_fact", return_value="curve25519-sha256\n mlkem768x25519-sha256 \n\n"): + assert _supported_kex_algorithms() == frozenset({"curve25519-sha256", "mlkem768x25519-sha256"}) + + def test_empty_output_yields_empty_set(self) -> None: + with patch.object(netsec_rune.host, "get_fact", return_value=""): + assert _supported_kex_algorithms() == frozenset() diff --git a/tests/test_runes_profiles.py b/tests/test_runes_profiles.py new file mode 100644 index 0000000..38081d8 --- /dev/null +++ b/tests/test_runes_profiles.py @@ -0,0 +1,114 @@ +from unittest.mock import MagicMock, patch + +from nullforge.models.profiles import NerdFont +from nullforge.runes.profiles import ZSHRC_MARKER, _configure_user_zshrc, _install_nerd_font +from nullforge.smithy.versions import DEFAULT_VERSIONS, Versions + + +def _configure(block_fact: object) -> MagicMock: + """Run the .zshrc helper against a given `Block` fact, returning the files operations mock.""" + + fake_host = MagicMock() + fake_host.get_fact.return_value = block_fact + fake_files = MagicMock() + + with ( + patch("nullforge.runes.profiles.host", fake_host), + patch("nullforge.runes.profiles.files", fake_files), + ): + _configure_user_zshrc("core", "/home/core") + + return fake_files + + +class TestZshrcMarker: + def test_marker_has_no_regex_metacharacters(self) -> None: + # the Block fact interpolates the marker straight into an awk regex + assert not set(ZSHRC_MARKER.replace("{mark}", "")) & set(".*+?[]()^$|\\/") + + +class TestConfigureUserZshrc: + def test_block_written_with_marker(self) -> None: + files_mock = _configure(["export ZSH=/home/core/.oh-my-zsh"]) + + kwargs = files_mock.block.call_args.kwargs + assert kwargs["path"] == "/home/core/.zshrc" + assert kwargs["marker"] == ZSHRC_MARKER + + def test_block_is_prepended(self) -> None: + files_mock = _configure(None) + + kwargs = files_mock.block.call_args.kwargs + # both true means prepend, so host-specific lines land after ours and win + assert kwargs["before"] is True + assert kwargs["after"] is True + + def test_content_rendered_for_home(self) -> None: + files_mock = _configure(None) + + content = files_mock.block.call_args.kwargs["content"] + assert 'export ZSH="/home/core/.oh-my-zsh"' in content + assert not content.endswith("\n") + + def test_unmarked_zshrc_is_cleared_with_a_backup(self) -> None: + files_mock = _configure([]) + + kwargs = files_mock.line.call_args.kwargs + assert kwargs["path"] == "/home/core/.zshrc" + assert kwargs["line"] == ".*" + assert kwargs["present"] is False + assert kwargs["backup"] is True + + def test_marked_zshrc_is_left_alone(self) -> None: + files_mock = _configure(["export ZSH=/home/core/.oh-my-zsh"]) + + files_mock.line.assert_not_called() + + def test_missing_zshrc_is_left_alone(self) -> None: + files_mock = _configure(None) + + files_mock.line.assert_not_called() + + +def _install_font(*, already_installed: bool, family: NerdFont = NerdFont.FIRA_CODE) -> tuple[MagicMock, MagicMock]: + """Run the font helper against a given `Directory` fact, returning the files/server mocks.""" + + fake_host = MagicMock() + fake_host.get_fact.return_value = already_installed + fake_files = MagicMock() + fake_server = MagicMock() + + with ( + patch("nullforge.runes.profiles.host", fake_host), + patch("nullforge.runes.profiles.files", fake_files), + patch("nullforge.runes.profiles.server", fake_server), + patch("nullforge.runes.profiles.sha256_for_download_url", return_value="deadbeef"), + patch("nullforge.runes.profiles.get_versions", return_value=Versions({})), + ): + _install_nerd_font("core", "/home/core", family) + + return fake_files, fake_server + + +class TestInstallNerdFont: + def test_skipped_when_already_installed(self) -> None: + files_mock, server_mock = _install_font(already_installed=True) + + files_mock.download.assert_not_called() + server_mock.shell.assert_not_called() + + def test_archive_downloaded_for_named_family(self) -> None: + files_mock, _ = _install_font(already_installed=False, family=NerdFont.JETBRAINS_MONO) + + kwargs = files_mock.download.call_args.kwargs + assert kwargs["src"].endswith("/JetBrainsMono.tar.xz") + assert kwargs["sha256sum"] == "deadbeef" + + def test_install_dir_is_version_pinned(self) -> None: + _, server_mock = _install_font(already_installed=False) + + version = DEFAULT_VERSIONS["nerd_fonts"] + commands = server_mock.shell.call_args.kwargs["commands"] + assert any(f"/home/core/.local/share/fonts/FiraCode-{version}" in cmd for cmd in commands) + # older versions of the same family are cleared so the two never mix + assert commands[0] == "rm -rf /home/core/.local/share/fonts/FiraCode-*" diff --git a/tests/test_runes_telemt.py b/tests/test_runes_telemt.py new file mode 100644 index 0000000..8ce7880 --- /dev/null +++ b/tests/test_runes_telemt.py @@ -0,0 +1,105 @@ +from unittest.mock import patch + +import pytest + +from nullforge.molds import TelemtMold +from nullforge.runes import telemt +from nullforge.runes.telemt import _tls_domain_verdict, _vet_tls_domain +from nullforge.smithy.sni import GROUP_SECP256R1, GROUP_X25519, GROUP_X25519MLKEM768, KeyExchangeReport + + +VALID_SECRET = "bf777cca8384a074a671460d51e4e31f" +DOMAIN = "masking.example" + + +def _opts(*, pq_check: bool = True, mode_tls: bool = True, mode_secure: bool = False) -> TelemtMold: + return TelemtMold( + install=True, + tls_domain=DOMAIN, + users={"u": VALID_SECRET}, + pq_check=pq_check, + mode_tls=mode_tls, + mode_secure=mode_secure, + ) + + +def _report( + *, + error: str | None = None, + supported: bool = False, + fallback: int | None = None, + supports_tls13: bool = True, +) -> KeyExchangeReport: + return KeyExchangeReport( + domain=DOMAIN, + group=GROUP_X25519MLKEM768, + error=error, + supported=supported, + fallback=fallback, + supports_tls13=supports_tls13, + ) + + +class TestGating: + def test_probes_when_fake_tls_is_configured(self) -> None: + with patch.object(telemt, "inspect_group", return_value=_report(supported=True)) as probe: + _vet_tls_domain(_opts()) + + probe.assert_called_once_with(DOMAIN, GROUP_X25519MLKEM768) + + def test_pq_check_disabled_skips_the_probe(self) -> None: + with patch.object(telemt, "inspect_group") as probe: + _vet_tls_domain(_opts(pq_check=False)) + + probe.assert_not_called() + + def test_no_probe_without_fake_tls(self) -> None: + with patch.object(telemt, "inspect_group") as probe: + _vet_tls_domain(_opts(mode_tls=False, mode_secure=True)) + + probe.assert_not_called() + + def test_no_probe_without_a_domain(self) -> None: + with patch.object(telemt, "inspect_group") as probe: + _vet_tls_domain(TelemtMold()) + + probe.assert_not_called() + + def test_supported_domain_is_reported_without_alarming(self) -> None: + with patch.object(telemt, "inspect_group", return_value=_report(supported=True)): + with patch.object(telemt, "LOG") as log: + _vet_tls_domain(_opts()) + + assert DOMAIN in log.info.call_args[0][0] + log.warning.assert_not_called() + + @pytest.mark.parametrize( + "report", + [ + _report(fallback=GROUP_X25519), + _report(fallback=GROUP_SECP256R1), + _report(supports_tls13=False), + _report(error="timed out"), + ], + ids=[ + "x25519-fallback", + "other-fallback", + "no-tls13", + "inconclusive", + ], + ) + def test_anything_short_of_a_pass_warns(self, report: KeyExchangeReport) -> None: + with patch.object(telemt, "inspect_group", return_value=report): + with patch.object(telemt, "LOG") as log: + _vet_tls_domain(_opts()) + + assert DOMAIN in log.warning.call_args[0][0] + + +class TestVerdictWording: + def test_inconclusive_probe_does_not_blame_the_domain(self) -> None: + verdict = _tls_domain_verdict(DOMAIN, _report(error="timed out")) + + assert "could not be vetted" in verdict + assert "timed out" in verdict + assert "blocked" not in verdict diff --git a/tests/test_runes_users.py b/tests/test_runes_users.py new file mode 100644 index 0000000..9b2f985 --- /dev/null +++ b/tests/test_runes_users.py @@ -0,0 +1,62 @@ +from unittest.mock import MagicMock, patch + +from pyinfra.facts.files import FileContents + +from nullforge.molds.user import UserMold +from nullforge.runes.users import _dedup_keys, _install_ssh_keys + + +class TestDedupKeys: + def test_drops_blanks_and_comments(self) -> None: + keys = ["ssh-ed25519 AAA a", "", " ", "# a comment", "ssh-rsa BBB b"] + assert _dedup_keys(keys) == ["ssh-ed25519 AAA a", "ssh-rsa BBB b"] + + def test_preserves_first_seen_order_across_duplicates(self) -> None: + keys = ["key-a", "key-b", "key-a", "key-c", "key-b"] + assert _dedup_keys(keys) == ["key-a", "key-b", "key-c"] + + def test_strips_surrounding_whitespace(self) -> None: + assert _dedup_keys([" ssh-ed25519 AAA a "]) == ["ssh-ed25519 AAA a"] + + +class TestInstallSshKeys: + def test_github_keys_installed(self) -> None: + opts = UserMold(copy_root_keys=False, fetch_key_from_github="octocat") + + fake_host = MagicMock() + fake_host.get_fact.return_value = "/home/core" + fake_server = MagicMock() + + with ( + patch("nullforge.runes.users.fetch_github_keys", return_value=["ssh-ed25519 GOOD octocat"]), + patch("nullforge.runes.users.host", fake_host), + patch("nullforge.runes.users.server", fake_server), + patch("nullforge.runes.users.files", MagicMock()), + ): + _install_ssh_keys(opts) + + fake_server.user_authorized_keys.assert_called_once() + assert fake_server.user_authorized_keys.call_args.kwargs["public_keys"] == ["ssh-ed25519 GOOD octocat"] + + def test_github_unavailable_keeps_root_keys(self) -> None: + opts = UserMold(copy_root_keys=True, fetch_key_from_github="bad") + + def _get_fact(fact, *args, **kwargs): + if fact is FileContents: + return ["ssh-rsa ROOT root"] + return "/home/core" + + fake_host = MagicMock() + fake_host.get_fact.side_effect = _get_fact + fake_server = MagicMock() + + with ( + patch("nullforge.runes.users.fetch_github_keys", return_value=[]), + patch("nullforge.runes.users.host", fake_host), + patch("nullforge.runes.users.server", fake_server), + patch("nullforge.runes.users.files", MagicMock()), + ): + _install_ssh_keys(opts) + + fake_server.user_authorized_keys.assert_called_once() + assert fake_server.user_authorized_keys.call_args.kwargs["public_keys"] == ["ssh-rsa ROOT root"] diff --git a/tests/test_smithy_arch.py b/tests/test_smithy_arch.py new file mode 100644 index 0000000..8aacbdb --- /dev/null +++ b/tests/test_smithy_arch.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock, patch + +from nullforge.smithy.arch import arch_id, deb_arch + + +class _FakeData: + pass + + +def _make_arch_host(arch_string: str) -> MagicMock: + host = MagicMock() + host.get_fact.return_value = arch_string + host.data = _FakeData() + return host + + +class TestArchId: + def _call(self, arch_string: str) -> str: + host = _make_arch_host(arch_string) + with patch("nullforge.smithy.arch.host", host): + return arch_id() + + def test_amd64_normalized(self) -> None: + assert self._call("amd64") == "x86_64" + + def test_aarch64_normalized(self) -> None: + assert self._call("aarch64") == "arm64" + + def test_unknown_passthrough(self) -> None: + assert self._call("riscv64") == "riscv64" + + def test_caching(self) -> None: + host = _make_arch_host("x86_64") + with patch("nullforge.smithy.arch.host", host): + arch_id() + arch_id() + assert host.get_fact.call_count == 1 + + +class TestDebArch: + def _call(self, arch_string: str) -> str: + host = _make_arch_host(arch_string) + with patch("nullforge.smithy.arch.host", host): + return deb_arch() + + def test_x86_64_returns_amd64(self) -> None: + assert self._call("x86_64") == "amd64" + + def test_arm64_returns_arm64(self) -> None: + assert self._call("arm64") == "arm64" + + def test_unknown_defaults_to_amd64(self) -> None: + assert self._call("riscv64") == "amd64" diff --git a/tests/test_smithy_github.py b/tests/test_smithy_github.py new file mode 100644 index 0000000..fd55b93 --- /dev/null +++ b/tests/test_smithy_github.py @@ -0,0 +1,153 @@ +from unittest.mock import patch + +import pytest + +from nullforge.smithy import github + + +@pytest.fixture(autouse=True) +def _clear_release_cache() -> None: + github._RELEASE_CACHE.clear() + + +SHA_A = "a" * 64 +SHA_B = "b" * 64 + + +def _release(assets: list[dict]) -> dict: + return {"tag_name": "v1.0.0", "assets": assets} + + +class TestParseDownloadUrl: + def test_tagged_url(self) -> None: + url = "https://github.com/telemt/telemt/releases/download/3.4.23/telemt-x86_64-linux-gnu.tar.gz" + assert github.parse_download_url(url) == ("telemt/telemt", "3.4.23", "telemt-x86_64-linux-gnu.tar.gz") + + def test_latest_url(self) -> None: + url = "https://github.com/direnv/direnv/releases/latest/download/direnv.linux-amd64" + assert github.parse_download_url(url) == ("direnv/direnv", "latest", "direnv.linux-amd64") + + def test_non_release_url_returns_none(self) -> None: + assert github.parse_download_url("https://example.com/foo/bar.tar.gz") is None + + +class TestAssetSha256: + def test_prefers_api_digest(self) -> None: + release = _release([{"name": "tool.tar.gz", "digest": f"sha256:{SHA_A}"}]) + assert github.asset_sha256(release, "tool.tar.gz") == SHA_A + + def test_ignores_malformed_digest(self) -> None: + release = _release([{"name": "tool.tar.gz", "digest": "sha256:nothex"}]) + with patch.object(github, "fetch_text", side_effect=AssertionError("should not fetch")): + assert github.asset_sha256(release, "tool.tar.gz") is None + + def test_falls_back_to_manifest(self) -> None: + release = _release( + [ + {"name": "tool.tar.gz", "browser_download_url": "https://x/tool.tar.gz"}, + {"name": "checksums.txt", "browser_download_url": "https://x/checksums.txt"}, + ] + ) + body = f"{SHA_B} tool.tar.gz\n{SHA_A} other.tar.gz\n" + with patch.object(github, "fetch_text", return_value=body) as fetched: + assert github.asset_sha256(release, "tool.tar.gz") == SHA_B + fetched.assert_called_once_with("https://x/checksums.txt") + + def test_per_asset_sha256_file(self) -> None: + release = _release( + [ + {"name": "tool.tar.gz", "browser_download_url": "https://x/tool.tar.gz"}, + {"name": "tool.tar.gz.sha256", "browser_download_url": "https://x/tool.tar.gz.sha256"}, + ] + ) + with patch.object(github, "fetch_text", return_value=f"{SHA_A}\n"): + assert github.asset_sha256(release, "tool.tar.gz") == SHA_A + + def test_no_checksum_available(self) -> None: + release = _release([{"name": "tool.tar.gz"}]) + assert github.asset_sha256(release, "tool.tar.gz") is None + + +class TestParseChecksums: + def test_star_prefixed_binary_mode(self) -> None: + assert github._parse_checksums(f"{SHA_A} *tool.tar.gz\n", "tool.tar.gz") == SHA_A + + def test_matches_basename_only(self) -> None: + assert github._parse_checksums(f"{SHA_A} ./dist/tool.tar.gz\n", "tool.tar.gz") == SHA_A + + def test_lone_hash_is_fallback(self) -> None: + assert github._parse_checksums(f" {SHA_A} \n", "tool.tar.gz") == SHA_A + + def test_named_line_wins_over_wrong_name(self) -> None: + body = f"{SHA_A} other\n{SHA_B} tool\n" + assert github._parse_checksums(body, "tool") == SHA_B + + +class TestSha256ForDownloadUrl: + def test_resolves_end_to_end(self) -> None: + url = "https://github.com/o/r/releases/download/v1/tool.tar.gz" + release = _release([{"name": "tool.tar.gz", "digest": f"sha256:{SHA_A}"}]) + with patch.object(github, "fetch_release", return_value=release) as fr: + assert github.sha256_for_download_url(url) == SHA_A + fr.assert_called_once_with("o/r", "v1") + + def test_unparseable_url_returns_none(self) -> None: + assert github.sha256_for_download_url("https://example.com/x") is None + + def test_missing_release_returns_none(self) -> None: + url = "https://github.com/o/r/releases/download/v1/tool.tar.gz" + with patch.object(github, "fetch_release", return_value=None): + assert github.sha256_for_download_url(url) is None + + +class TestSelectAsset: + def _sample(self) -> dict: + return _release( + [ + {"name": "tool-linux-x86_64-gnu.tar.gz"}, + {"name": "tool-linux-x86_64-musl.tar.gz"}, + {"name": "tool-linux-aarch64-gnu.tar.gz"}, + {"name": "tool-darwin-arm64.tar.gz"}, + {"name": "checksums.txt"}, + {"name": "tool-linux-x86_64.tar.gz.sha256"}, + ] + ) + + def test_picks_x86_64_gnu(self) -> None: + asset = github.select_asset(self._sample(), arch="x86_64", os_name="linux") + assert asset is not None + assert asset["name"] == "tool-linux-x86_64-gnu.tar.gz" + + def test_picks_arm64(self) -> None: + asset = github.select_asset(self._sample(), arch="arm64", os_name="linux") + assert asset is not None + assert asset["name"] == "tool-linux-aarch64-gnu.tar.gz" + + def test_excludes_musl_hint(self) -> None: + asset = github.select_asset(self._sample(), arch="x86_64", os_name="linux", exclude=("gnu",)) + assert asset is not None + assert asset["name"] == "tool-linux-x86_64-musl.tar.gz" + + def test_skips_checksum_and_sig_assets(self) -> None: + asset = github.select_asset(self._sample(), arch="x86_64", os_name="linux") + assert asset is not None + assert not asset["name"].endswith((".sha256", ".txt")) + + def test_no_match_returns_none(self) -> None: + release = _release([{"name": "tool-windows-x86_64.zip"}]) + assert github.select_asset(release, arch="x86_64", os_name="linux") is None + + +class TestFetchReleaseCaching: + def test_caches_per_repo_tag(self) -> None: + with patch.object(github, "fetch_text", return_value="{}") as fetched: + fetched.return_value = '{"assets": []}' + github.fetch_release("o/r", "v1") + github.fetch_release("o/r", "v1") + assert fetched.call_count == 1 + + def test_failure_is_cached_as_none(self) -> None: + with patch.object(github, "fetch_text", side_effect=OSError("boom")) as fetched: + assert github.fetch_release("o/r", "v1") is None + assert github.fetch_release("o/r", "v1") is None + assert fetched.call_count == 1 diff --git a/tests/test_smithy_http.py b/tests/test_smithy_http.py new file mode 100644 index 0000000..27bfab7 --- /dev/null +++ b/tests/test_smithy_http.py @@ -0,0 +1,30 @@ +from unittest.mock import patch + +import pytest + +from nullforge.smithy.http import fetch_github_keys, fetch_text + + +class TestFetchText: + def test_rejects_non_https_url(self) -> None: + with pytest.raises(ValueError, match="Refusing non-HTTPS URL"): + fetch_text("http://github.com/octocat.keys") + + +class TestFetchGithubKeys: + def test_none_returns_empty(self) -> None: + assert fetch_github_keys(None) == [] + + def test_returns_lines(self) -> None: + with patch( + "nullforge.smithy.http.fetch_text", + return_value="ssh-ed25519 AAA a\nssh-rsa BBB b\n", + ): + assert fetch_github_keys("octocat") == ["ssh-ed25519 AAA a", "ssh-rsa BBB b"] + + def test_fetch_failure_warns_and_returns_empty(self) -> None: + with patch( + "nullforge.smithy.http.fetch_text", + side_effect=RuntimeError("404"), + ): + assert fetch_github_keys("bad") == [] diff --git a/tests/test_smithy_install.py b/tests/test_smithy_install.py new file mode 100644 index 0000000..cba56e8 --- /dev/null +++ b/tests/test_smithy_install.py @@ -0,0 +1,119 @@ +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from nullforge.smithy import install + + +class TestDetectArchive: + @pytest.mark.parametrize( + ("name", "expected"), + [ + ("tool-1.2.3.tar.gz", "tar.gz"), + ("tool.tgz", "tar.gz"), + ("tool.tar.xz", "tar.xz"), + ("tool.txz", "tar.xz"), + ("tool.tar.bz2", "tar.bz2"), + ("tool.tar", "tar"), + ("tool.zip", "zip"), + ("tool.AppImage", "raw"), + ("tool-linux-amd64.gz", "gzip"), + ("direnv.linux-amd64", "raw"), + ("https://github.com/o/r/releases/download/v1/tool.tar.gz", "tar.gz"), + ], + ) + def test_detects_kind(self, name: str, expected: str) -> None: + assert install.detect_archive(name) == expected + + +class TestExtractCommands: + def test_raw_is_not_extractable(self) -> None: + with pytest.raises(ValueError, match="not an extractable"): + install._extract_commands("raw", "/tmp/tool", "/tmp/work", "tool") + + +@pytest.fixture +def _wired() -> Generator[MagicMock]: + """Patch pyinfra ops + checksum/curl helpers; yield a namespace of the mocks.""" + + ns = MagicMock() + with ( + patch.object(install, "files", ns.files), + patch.object(install, "server", ns.server), + patch.object(install, "sha256_for_download_url", ns.resolve), + patch.object(install.Versions, "release_curl_args", return_value={"--resolve": "host:443:1.2.3.4"}), + ): + ns.resolve.return_value = "c" * 64 + yield ns + + +class TestInstallReleaseBinary: + def test_archive_downloads_then_installs(self, _wired: MagicMock) -> None: + url = "https://github.com/o/r/releases/download/v1/telemt-x86_64-linux-gnu.tar.gz" + result = install.install_release_binary(name="Install telemt", url=url, dest="/usr/local/bin/telemt") + + staged = f"{install.STAGING_DIR}/telemt-x86_64-linux-gnu.tar.gz" + + directory = _wired.files.directory.call_args.kwargs + assert directory["path"] == install.STAGING_DIR + assert directory["mode"] == "0700" + assert not install.STAGING_DIR.startswith("/tmp") + + dl = _wired.files.download.call_args.kwargs + assert dl["src"] == url + assert dl["dest"] == staged + assert dl["sha256sum"] == "c" * 64 + assert dl["force"] is True + assert dl["_sudo"] is True + + shell = _wired.server.shell.call_args.kwargs + commands = "\n".join(shell["commands"]) + assert f"tar -xzf {staged}" in commands + assert "-name telemt" in commands + assert "install -D -m 0755" in commands + assert shell["_sudo"] is True + # returns the install op (server.shell), not the download + assert result is _wired.server.shell.return_value + + def test_raw_binary_is_single_download(self, _wired: MagicMock) -> None: + url = "https://github.com/direnv/direnv/releases/download/v2.37.1/direnv.linux-amd64" + result = install.install_release_binary( + name="Install direnv", url=url, dest="/usr/local/bin/direnv", mode="0755" + ) + + _wired.server.shell.assert_not_called() + dl = _wired.files.download.call_args.kwargs + assert dl["dest"] == "/usr/local/bin/direnv" + assert dl["mode"] == "0755" + assert dl["sha256sum"] == "c" * 64 + assert result is _wired.files.download.return_value + + def test_explicit_sha256_skips_resolution(self, _wired: MagicMock) -> None: + install.install_release_binary( + name="x", + url="https://github.com/o/r/releases/download/v1/x.tar.gz", + dest="/usr/local/bin/x", + sha256="d" * 64, + ) + _wired.resolve.assert_not_called() + assert _wired.files.download.call_args.kwargs["sha256sum"] == "d" * 64 + + def test_verify_false_skips_checksum(self, _wired: MagicMock) -> None: + install.install_release_binary( + name="x", + url="https://github.com/o/r/releases/download/v1/x.tar.gz", + dest="/usr/local/bin/x", + verify=False, + ) + _wired.resolve.assert_not_called() + assert _wired.files.download.call_args.kwargs["sha256sum"] is None + + def test_binary_name_defaults_to_dest_basename(self, _wired: MagicMock) -> None: + install.install_release_binary( + name="x", + url="https://github.com/o/r/releases/download/v1/x.tar.gz", + dest="/usr/local/bin/mytool", + ) + commands = "\n".join(_wired.server.shell.call_args.kwargs["commands"]) + assert "-name mytool" in commands diff --git a/tests/test_smithy_network.py b/tests/test_smithy_network.py new file mode 100644 index 0000000..1fbb27a --- /dev/null +++ b/tests/test_smithy_network.py @@ -0,0 +1,128 @@ +from unittest.mock import MagicMock, patch + +from nullforge.smithy.network import conntrack_max_for, has_ipv6, module_loaded + + +class _FakeData: + pass + + +def _make_host(grub_contents: list[str] | None = None, ip6_raw: str = "") -> MagicMock: + host = MagicMock() + host.data = _FakeData() + + def get_fact(fact_class, *args, **kwargs): + name = fact_class.__name__ if hasattr(fact_class, "__name__") else str(fact_class) + if "FileContents" in name: + return grub_contents + return ip6_raw + + host.get_fact.side_effect = get_fact + return host + + +def _raw_native(addr: str = "2001:db8::1/64", iface: str = "eth0") -> str: + return f"2: {iface} inet6 {addr} scope global" + + +class TestHasIpv6: + def _call(self, grub: list[str] | None = None, ip6_raw: str = "") -> bool: + host = _make_host(grub, ip6_raw) + with patch("nullforge.smithy.network.host", host): + return has_ipv6() + + def test_disabled_in_grub(self) -> None: + grub = ['GRUB_CMDLINE_LINUX_DEFAULT="ipv6.disable=1 quiet"'] + assert self._call(grub=grub) is False + + def test_enabled_by_native_ipv6(self) -> None: + assert self._call(ip6_raw=_raw_native()) is True + + def test_zero_ip6_interfaces(self) -> None: + assert self._call(ip6_raw="") is False + + def test_no_grub_file(self) -> None: + assert self._call(grub=None, ip6_raw=_raw_native()) is True + + def test_grub_without_ipv6_disable(self) -> None: + grub = ['GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"'] + assert self._call(grub=grub, ip6_raw=_raw_native()) is True + + def test_raw_facts_fetched_at_most_once_across_variants(self) -> None: + host = _make_host(ip6_raw=_raw_native()) + with patch("nullforge.smithy.network.host", host): + assert has_ipv6(exclude_iface="warp") is True + assert has_ipv6() is True + assert has_ipv6(exclude_iface="warp") is True + assert has_ipv6() is True + assert has_ipv6(exclude_iface="eth0") is False + assert host.get_fact.call_count == 2 + + def test_raw_facts_fetched_at_most_once_plain_first(self) -> None: + host = _make_host(ip6_raw=_raw_native()) + with patch("nullforge.smithy.network.host", host): + assert has_ipv6() is True + assert has_ipv6() is True + assert has_ipv6(exclude_iface="warp") is True + assert has_ipv6(exclude_iface="eth0") is False + assert host.get_fact.call_count == 2 + + def test_warp_interface_ignored(self) -> None: + raw = "3: warp inet6 2606:4700:103::1/128 scope global" + assert self._call(ip6_raw=raw) is False + + def test_only_tunnel_ifaces_yield_false_even_with_other_tunnels(self) -> None: + raw = ( + "3: warp inet6 2606:4700:103::1/128 scope global\n" + "5: wg0 inet6 fe80::1/64 scope global\n" # link-local shouldn't count anyway + "6: docker0 inet6 2001:db8:dead::1/64 scope global" + ) + assert self._call(ip6_raw=raw) is False + + +class TestConntrackMaxFor: + def test_floor_for_tiny_ram(self) -> None: + # 128 MB -> ~8947 entries -> clamped up to the 16384 floor + assert conntrack_max_for(128) == 16384 + + def test_ceiling_for_huge_ram(self) -> None: + # 256 GB -> way above the ceiling -> clamped to 512Ki + assert conntrack_max_for(256 * 1024) == 512 * 1024 + + def test_scales_with_ram(self) -> None: + # 8 GB -> 8192 * 1024 * 1024 * 0.02 / 300 ~ 572662 -> clamped to ceiling + assert conntrack_max_for(8192) == 512 * 1024 + # 2 GB -> 2048 * 1024 * 1024 * 0.02 / 300 ~ 143165 -> within bounds + assert conntrack_max_for(2048) == int(2048 * 1024 * 1024 * 0.02 / 300) + + +def _make_modules_host(modules: object) -> MagicMock: + host = MagicMock() + host.data = _FakeData() + host.get_fact.return_value = modules + return host + + +class TestModuleLoaded: + def _call(self, modules: object, module: str) -> bool: + host = _make_modules_host(modules) + with patch("nullforge.smithy.network.host", host): + return module_loaded(module) + + def test_true_when_present(self) -> None: + assert self._call({"nf_conntrack": {"size": "180224"}}, "nf_conntrack") is True + + def test_false_when_absent(self) -> None: + assert self._call({"overlay": {"size": "0"}}, "nf_conntrack") is False + + def test_false_when_fact_empty(self) -> None: + assert self._call(None, "nf_conntrack") is False + + def test_caching_shared_across_modules(self) -> None: + host = _make_modules_host({"nf_conntrack": {}, "overlay": {}}) + with patch("nullforge.smithy.network.host", host): + assert module_loaded("nf_conntrack") is True + assert module_loaded("overlay") is True + assert module_loaded("br_netfilter") is False + # single fact gather serves every subsequent module check + assert host.get_fact.call_count == 1 diff --git a/tests/test_smithy_nezha.py b/tests/test_smithy_nezha.py new file mode 100644 index 0000000..1a1ae42 --- /dev/null +++ b/tests/test_smithy_nezha.py @@ -0,0 +1,44 @@ +import uuid +from typing import Any + +from nullforge.smithy.monitoring.nezha import agent as nezha + + +class TestAgentUuidForHostname: + def test_is_valid_uuid(self) -> None: + value = nezha.agent_uuid_for_hostname("nl120.aphelion") + assert str(uuid.UUID(value)) == value + + def test_namespace_changes_result(self) -> None: + default = nezha.agent_uuid_for_hostname("host") + other = nezha.agent_uuid_for_hostname("host", namespace=str(uuid.uuid4())) + assert default != other + + def test_namespace_accepts_uuid_object(self) -> None: + ns = uuid.uuid4() + one = nezha.agent_uuid_for_hostname("host", namespace=ns) + other = nezha.agent_uuid_for_hostname("host", namespace=str(ns)) + assert one == other + + +class TestBuildNezhaInstallCommand: + def _build(self, **overrides: object) -> str: + kwargs: dict[str, Any] = { + "install_url": "https://example.com/install.sh", + "curl_args": "--retry 3", + "server": "agent.status.example.com:443", + "tls": True, + "client_secret": "s3cr3t", + "agent_uuid": "39bdee03-5773-eb88-97d9-f1ebd46bf3f1", + } + kwargs.update(overrides) + return nezha.build_nezha_install_command(**kwargs) + + def test_tls_flag(self) -> None: + assert "NZ_TLS=true" in self._build(tls=True) + assert "NZ_TLS=false" in self._build(tls=False) + + def test_shell_metacharacters_are_quoted(self) -> None: + cmd = self._build(client_secret="a b; rm -rf /") + # the dangerous value must be single-quoted, not bare + assert "a b; rm -rf /" not in cmd.replace("'a b; rm -rf /'", "") diff --git a/tests/test_smithy_nezha_dashboard.py b/tests/test_smithy_nezha_dashboard.py new file mode 100644 index 0000000..c33322c --- /dev/null +++ b/tests/test_smithy_nezha_dashboard.py @@ -0,0 +1,84 @@ +import urllib.request +from typing import Any + +import pytest + +from nullforge.smithy.monitoring.nezha import dashboard as nezha_dashboard + + +class _FakeResponse: + def __init__(self, body: bytes) -> None: + self._body = body + + def __enter__(self) -> "_FakeResponse": + return self + + def __exit__(self, *exc: object) -> None: + return None + + def read(self) -> bytes: + return self._body + + +class TestApiRequest: + def test_sets_non_default_user_agent(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + def fake_urlopen(request: urllib.request.Request, timeout: float = 0) -> _FakeResponse: + captured["ua"] = request.get_header("User-agent") + return _FakeResponse(b'{"success": true, "data": []}') + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + nezha_dashboard._api_request("https://dash.example.com/api/v1/server", "tok") + + # Cloudflare-fronted dashboards 403 the default Python-urllib agent. + assert captured["ua"] == nezha_dashboard.USER_AGENT + assert "python-urllib" not in captured["ua"].lower() + + +class TestRenameNezhaServer: + def test_patches_when_name_differs(self, monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[str, str, dict[str, object] | None]] = [] + + def fake_api(url: str, token: str, *, method: str = "GET", payload=None): # noqa: ANN001, ANN202 + calls.append((url, method, payload)) + if method == "GET": + return {"success": True, "data": [{"id": 7, "uuid": "u", "name": "petname-here"}]} + return {"success": True} + + monkeypatch.setattr(nezha_dashboard, "_api_request", fake_api) + nezha_dashboard.rename_nezha_server( + dashboard_url="https://dash.example.com", + api_token="t", + agent_uuid="u", + desired_name="node-1", + ) + + patch_calls = [c for c in calls if c[1] == "PATCH"] + assert patch_calls == [("https://dash.example.com/api/v1/server/7", "PATCH", {"name": "node-1"})] + + def test_idempotent_when_name_matches(self, monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + def fake_api(url: str, token: str, *, method: str = "GET", payload=None): # noqa: ANN001, ANN202 + calls.append(method) + return {"success": True, "data": [{"id": 7, "uuid": "u", "name": "node-1"}]} + + monkeypatch.setattr(nezha_dashboard, "_api_request", fake_api) + nezha_dashboard.rename_nezha_server( + dashboard_url="https://dash.example.com", api_token="t", agent_uuid="u", desired_name="node-1" + ) + assert "PATCH" not in calls + + def test_skips_when_unregistered(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(nezha_dashboard, "REGISTER_POLL_ATTEMPTS", 1) + monkeypatch.setattr(nezha_dashboard, "REGISTER_POLL_DELAY", 0) + + def fake_api(url: str, token: str, *, method: str = "GET", payload=None): # noqa: ANN001, ANN202 + return {"success": True, "data": []} + + monkeypatch.setattr(nezha_dashboard, "_api_request", fake_api) + # must not raise — unregistered agent is logged and skipped + nezha_dashboard.rename_nezha_server( + dashboard_url="https://dash.example.com", api_token="t", agent_uuid="u", desired_name="node-1" + ) diff --git a/tests/test_smithy_packages.py b/tests/test_smithy_packages.py new file mode 100644 index 0000000..e2826f7 --- /dev/null +++ b/tests/test_smithy_packages.py @@ -0,0 +1,44 @@ +from unittest.mock import MagicMock + +from nullforge.smithy.packages import PackageManager + + +def _make_pm(distro_name: str, major: int = 0) -> PackageManager: + host = MagicMock() + host.get_fact.return_value = {"name": distro_name, "major": major} + host.data = MagicMock() + return PackageManager(host) + + +class TestDebian: + def test_passthrough_known_package(self) -> None: + pm = _make_pm("ubuntu") + assert pm.map_package("curl") == "curl" + + def test_ubuntu_override(self) -> None: + pm = _make_pm("ubuntu") + assert pm.map_package("ifupdown2") == "ifupdown" + + def test_debian_no_override(self) -> None: + pm = _make_pm("debian") + assert pm.map_package("ifupdown2") == "ifupdown2" + + +class TestRhel: + def test_known_override(self) -> None: + pm = _make_pm("rhel") + assert pm.map_package("dnsutils") == "bind-utils" + + def test_none_skip(self) -> None: + pm = _make_pm("fedora") + assert pm.map_package("apt-transport-https") is None + + def test_passthrough_unknown(self) -> None: + pm = _make_pm("centos") + assert pm.map_package("some-unknown-pkg") == "some-unknown-pkg" + + def test_map_packages_skips_none(self) -> None: + pm = _make_pm("rhel") + result = pm.map_packages(["dnsutils", "apt-transport-https"]) + assert "bind-utils" in result + assert None not in result diff --git a/tests/test_smithy_service.py b/tests/test_smithy_service.py new file mode 100644 index 0000000..3d97f39 --- /dev/null +++ b/tests/test_smithy_service.py @@ -0,0 +1,61 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from nullforge.smithy.admin import is_root +from nullforge.smithy.service import ensure_service_user + + +class _FakeData: + pass + + +def _make_service_context(groups: list[str], users: list[str]) -> tuple[MagicMock, MagicMock]: + """Create a patched host and server mock for ensure_service_user tests.""" + host = MagicMock() + host.data = _FakeData() + host.get_fact.side_effect = lambda fact, **kw: groups if "Groups" in str(fact) else users + server = MagicMock() + return host, server + + +class TestIsRoot: + def test_caching(self) -> None: + host = MagicMock() + host.data = _FakeData() + host.get_fact.return_value = "root" + with patch("nullforge.smithy.admin.host", host): + is_root() + is_root() + assert host.get_fact.call_count == 1 + + +@pytest.mark.parametrize( + "groups,users,expect_group_create,expect_user_create", + [ + ([], [], True, True), + ([], ["myuser"], True, False), + (["mygroup"], [], False, True), + (["mygroup"], ["myuser"], False, False), + ], +) +def test_ensure_service_user( + groups: list[str], + users: list[str], + expect_group_create: bool, + expect_user_create: bool, +) -> None: + host, server = _make_service_context(groups, users) + with patch("nullforge.smithy.service.host", host): + with patch("nullforge.smithy.service.server", server): + with patch("nullforge.smithy.service.files", MagicMock()): + ensure_service_user("myuser", "mygroup", "/etc/myservice") + assert server.group.called is expect_group_create + assert server.user.called is expect_user_create + if expect_group_create: + group_call_args = server.group.call_args + assert group_call_args.kwargs.get("system") is True + if expect_user_create: + user_call_args = server.user.call_args + assert user_call_args.kwargs.get("system") is True + assert user_call_args.kwargs.get("create_home") is False diff --git a/tests/test_smithy_sni.py b/tests/test_smithy_sni.py new file mode 100644 index 0000000..53c583a --- /dev/null +++ b/tests/test_smithy_sni.py @@ -0,0 +1,224 @@ +import struct +from unittest.mock import patch + +import pytest + +from nullforge.smithy import sni +from nullforge.smithy.sni import ( + CLASSICAL_GROUPS, + GROUP_SECP256R1, + GROUP_X25519, + GROUP_X25519MLKEM768, + KeyExchangeReport, + group_name, + inspect_group, +) + + +HRR_RANDOM = bytes.fromhex("cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c") +"""RFC 8446 sentinel marking a ServerHello as a HelloRetryRequest.""" + + +def _server_hello(group: int | None, *, hrr: bool = True, version: bytes = b"\x03\x04") -> bytes: + """Build a ServerHello/HelloRetryRequest record naming `group` in its key_share.""" + + extensions = struct.pack(">HH", 0x002B, len(version)) + version + if group is not None: + # An HRR names the group alone; a real ServerHello follows it with a key_exchange. + share = struct.pack(">H", group) if hrr else struct.pack(">HH", group, 32) + bytes(32) + extensions += struct.pack(">HH", 0x0033, len(share)) + share + + body = ( + b"\x03\x03" + + (HRR_RANDOM if hrr else bytes(32)) + + b"\x00" # empty legacy_session_id_echo + + b"\x13\x01" # cipher_suite + + b"\x00" # compression + + struct.pack(">H", len(extensions)) + + extensions + ) + handshake = b"\x02" + len(body).to_bytes(3, "big") + body + return b"\x16\x03\x03" + struct.pack(">H", len(handshake)) + handshake + + +def _alert(desc: int) -> bytes: + return b"\x15\x03\x03\x00\x02" + bytes([2, desc]) + + +class FakeSocket: + """Socket stub that replays a canned server flight and records what was sent.""" + + def __init__(self, response: bytes) -> None: + self.response = response + self.sent = b"" + self.pos = 0 + + def sendall(self, data: bytes) -> None: + self.sent += data + + def recv(self, count: int) -> bytes: + chunk = self.response[self.pos : self.pos + count] + self.pos += len(chunk) + return chunk + + def settimeout(self, timeout: float) -> None: + pass + + def __enter__(self) -> "FakeSocket": + return self + + def __exit__(self, *args: object) -> bool: + return False + + +@pytest.fixture(autouse=True) +def _clear_cache() -> None: + sni._cache.clear() + + +def _probe(domain: str, *responses: bytes) -> tuple[KeyExchangeReport, list[FakeSocket]]: + """Probe for the PQ group against a scripted sequence of server responses.""" + + sockets = [FakeSocket(r) for r in responses] + with patch.object(sni.socket, "create_connection", side_effect=list(sockets)): + return inspect_group(domain, GROUP_X25519MLKEM768), sockets + + +class TestGroupName: + def test_unknown_group_falls_back_to_hex(self) -> None: + assert group_name(0x1234) == "0x1234" + + def test_none(self) -> None: + assert group_name(None) == "none" + + +class TestClientHello: + def test_offers_requested_groups_and_empty_key_share(self) -> None: + hello = sni._client_hello("example.com", (GROUP_X25519MLKEM768,)) + + assert hello.startswith(b"\x16\x03\x01") + # supported_groups carrying exactly the probed group... + assert struct.pack(">HHH H", 0x000A, 4, 2, GROUP_X25519MLKEM768) in hello + # ...and a key_share whose client_shares vector is empty, which is what + # makes the server name its own choice instead of using ours. + assert struct.pack(">HHH", 0x0033, 2, 0) in hello + + def test_key_share_group_sends_a_32_byte_guess(self) -> None: + hello = sni._client_hello("example.com", CLASSICAL_GROUPS, key_share_group=GROUP_X25519) + + # key_share ext (38 body bytes) -> client_shares vector (36) -> x25519 entry (32). + assert struct.pack(">HHHHH", 0x0033, 38, 36, GROUP_X25519, 32) in hello + + def test_includes_sni(self) -> None: + hello = sni._client_hello("example.com", (GROUP_X25519,)) + assert b"example.com" in hello + + def test_encodes_idna_domain(self) -> None: + hello = sni._client_hello("münchen.de", (GROUP_X25519,)) + assert b"xn--mnchen-3ya.de" in hello + + +class TestInspectGroup: + def test_group_supported(self) -> None: + report, sockets = _probe("good.example", _server_hello(GROUP_X25519MLKEM768)) + + assert report.conclusive + assert report.supported is True + assert report.fallback is None + # A positive answer must not cost a second round trip. + assert len(sockets) == 1 + + def test_refusal_reports_the_fallback_group(self) -> None: + report, _ = _probe("bad.example", _alert(40), _server_hello(GROUP_X25519)) + + assert report.conclusive + assert report.supported is False + assert report.fallback == GROUP_X25519 + + def test_server_taking_the_x25519_guess_reports_x25519(self) -> None: + report, sockets = _probe("msft.example", _alert(40), _server_hello(GROUP_X25519, hrr=False)) + + assert report.fallback == GROUP_X25519 + # The fallback probe must guess, or it would have measured server preference. + assert struct.pack(">HH", GROUP_X25519, 32) in sockets[1].sent + + def test_fallback_probe_does_not_reoffer_the_probed_group(self) -> None: + report, sockets = _probe("bad.example", _alert(40), _server_hello(GROUP_SECP256R1)) + + assert report.fallback == GROUP_SECP256R1 + assert struct.pack(">H", GROUP_X25519MLKEM768) not in sockets[1].sent + + def test_full_server_hello_is_accepted(self) -> None: + report, _ = _probe("good.example", _server_hello(GROUP_X25519MLKEM768, hrr=False)) + assert report.supported is True + + def test_insufficient_security_alert_counts_as_refusal(self) -> None: + report, _ = _probe("bad.example", _alert(71), _server_hello(GROUP_X25519)) + + assert report.supported is False + assert report.fallback == GROUP_X25519 + + def test_tls12_only_peer_is_conclusive_not_an_error(self) -> None: + report, sockets = _probe("old.example", _server_hello(GROUP_X25519, version=b"\x03\x03")) + + assert report.conclusive + assert report.supports_tls13 is False + assert report.supported is False + # Settled by the first probe; there is nothing a fallback could add. + assert len(sockets) == 1 + + def test_protocol_version_alert_is_conclusive(self) -> None: + report, _ = _probe("old.example", _alert(70)) + + assert report.conclusive + assert report.supports_tls13 is False + + def test_unexpected_alert_is_inconclusive(self) -> None: + report, _ = _probe("weird.example", _alert(80)) + + assert report.conclusive is False + assert "alert 80" in str(report.error) + + def test_unreachable_host_is_inconclusive(self) -> None: + with patch.object(sni.socket, "create_connection", side_effect=OSError("timed out")): + report = inspect_group("nope.example", GROUP_X25519MLKEM768) + + assert report.conclusive is False + assert "timed out" in str(report.error) + + def test_failed_fallback_probe_is_inconclusive(self) -> None: + with patch.object( + sni.socket, + "create_connection", + side_effect=[FakeSocket(_alert(40)), OSError("reset")], + ): + report = inspect_group("half.example", GROUP_X25519MLKEM768) + + assert report.conclusive is False + assert "fallback probe failed" in str(report.error) + + def test_truncated_response_is_inconclusive(self) -> None: + report, _ = _probe("cut.example", b"\x16\x03\x03\x00\x40") + assert report.conclusive is False + + def test_never_raises_on_unexpected_errors(self) -> None: + with patch.object(sni, "_inspect", side_effect=RuntimeError("boom")): + report = inspect_group("boom.example", GROUP_X25519MLKEM768) + + assert report.conclusive is False + assert "boom" in str(report.error) + + +class TestCaching: + def test_domain_is_probed_once_per_group(self) -> None: + response = _server_hello(GROUP_X25519MLKEM768) + with patch.object( + sni.socket, + "create_connection", + side_effect=[FakeSocket(response), FakeSocket(response)], + ) as connect: + first = inspect_group("cached.example", GROUP_X25519MLKEM768) + second = inspect_group("cached.example", GROUP_X25519MLKEM768) + + assert first == second + assert connect.call_count == 1 diff --git a/tests/test_smithy_swap.py b/tests/test_smithy_swap.py new file mode 100644 index 0000000..d8341b6 --- /dev/null +++ b/tests/test_smithy_swap.py @@ -0,0 +1,54 @@ +from unittest.mock import patch + +from nullforge.models.system import SwapAlgo, SwapType +from nullforge.molds.system import SwapMold, SystemMold +from nullforge.smithy.swap import configure_swap + + +def _make_system(enabled: bool = True, swap_type: SwapType = SwapType.ZRAM, size: str = "2G") -> SystemMold: + return SystemMold(swap=SwapMold(enabled=enabled, type=swap_type, size=size)) + + +class TestConfigureSwap: + def test_disabled_calls_disable(self) -> None: + system = _make_system(enabled=False) + with patch("nullforge.smithy.swap._disable_swap") as mock_disable: + with patch("nullforge.smithy.swap._set_swappiness"): + configure_swap(system) + mock_disable.assert_called_once() + + def test_disabled_skips_configure(self) -> None: + system = _make_system(enabled=False) + with patch("nullforge.smithy.swap._disable_swap"): + with patch("nullforge.smithy.swap._configure_zram") as mock_zram: + with patch("nullforge.smithy.swap._configure_basic_swap") as mock_basic: + configure_swap(system) + mock_zram.assert_not_called() + mock_basic.assert_not_called() + + def test_zram_calls_configure_zram(self) -> None: + system = _make_system(enabled=True, swap_type=SwapType.ZRAM, size="4G") + with patch("nullforge.smithy.swap._disable_basic_swap"): + with patch("nullforge.smithy.swap._configure_zram") as mock_zram: + with patch("nullforge.smithy.swap._set_swappiness"): + with patch("nullforge.smithy.swap._disable_zram"): + configure_swap(system) + mock_zram.assert_called_once_with("4G", SwapAlgo.ZSTD) + + def test_file_swap_calls_configure_basic(self) -> None: + system = _make_system(enabled=True, swap_type=SwapType.BASIC, size="1G") + with patch("nullforge.smithy.swap._disable_zram"): + with patch("nullforge.smithy.swap._configure_basic_swap") as mock_basic: + with patch("nullforge.smithy.swap._set_swappiness"): + with patch("nullforge.smithy.swap._disable_basic_swap"): + configure_swap(system) + mock_basic.assert_called_once_with("1G") + + def test_swappiness_always_set_when_enabled(self) -> None: + system = _make_system(enabled=True, swap_type=SwapType.ZRAM) + with patch("nullforge.smithy.swap._disable_basic_swap"): + with patch("nullforge.smithy.swap._configure_zram"): + with patch("nullforge.smithy.swap._set_swappiness") as mock_sw: + with patch("nullforge.smithy.swap._disable_zram"): + configure_swap(system) + mock_sw.assert_called_once_with(system.swap.swappiness) diff --git a/tests/test_smithy_system.py b/tests/test_smithy_system.py new file mode 100644 index 0000000..fcec9d6 --- /dev/null +++ b/tests/test_smithy_system.py @@ -0,0 +1,101 @@ +from unittest.mock import MagicMock, patch + +from nullforge.smithy.system import detect_best_locale, get_supported_locales, get_total_memory + + +class _FakeData: + pass + + +def _make_memory_host(memory: object) -> MagicMock: + host = MagicMock() + host.data = _FakeData() + host.get_fact.return_value = memory + return host + + +class TestGetTotalMemoryMb: + def _call(self, memory: object, default: int = 1024) -> int: + host = _make_memory_host(memory) + with patch("nullforge.smithy.system.host", host): + return get_total_memory(default=default) + + def test_falls_back_to_default_when_none(self) -> None: + assert self._call(None, default=512) == 512 + + def test_caching(self) -> None: + host = _make_memory_host(4096) + with patch("nullforge.smithy.system.host", host): + assert get_total_memory() == 4096 + assert get_total_memory() == 4096 + assert host.get_fact.call_count == 1 + + +def _make_locale_host(locale_gen_lines: list[str] | None) -> MagicMock: + host = MagicMock() + host.get_fact.return_value = locale_gen_lines + host.data = _FakeData() + return host + + +class TestGetSupportedLocales: + def _call(self, lines: list[str] | None) -> list[str]: + host = _make_locale_host(lines) + with patch("nullforge.smithy.system.host", host): + return get_supported_locales() + + def test_none_returns_empty(self) -> None: + assert self._call(None) == [] + + def test_parses_enabled_locale(self) -> None: + result = self._call(["en_US.UTF-8 UTF-8"]) + assert "en_US.UTF-8 UTF-8" in result + + def test_strips_comment_prefix(self) -> None: + result = self._call(["# en_GB.UTF-8 UTF-8"]) + assert "en_GB.UTF-8 UTF-8" in result + + def test_skips_blank_lines(self) -> None: + result = self._call(["", "en_US.UTF-8 UTF-8", ""]) + assert len(result) == 1 + + def test_caching(self) -> None: + host = _make_locale_host(["en_US.UTF-8 UTF-8"]) + with patch("nullforge.smithy.system.host", host): + get_supported_locales() + get_supported_locales() + assert host.get_fact.call_count == 1 + + +class TestDetectBestLocale: + EN_US = "en_US.UTF-8 UTF-8" + EN_GB = "en_GB.UTF-8 UTF-8" + FR_FR = "fr_FR.UTF-8 UTF-8" + + def _call(self, supported: list[str], preferred: str | None = None) -> str | None: + host = _make_locale_host(supported) + with patch("nullforge.smithy.system.host", host): + return detect_best_locale(preferred=preferred) + + def test_returns_none_when_empty(self) -> None: + assert self._call([]) is None + + def test_exact_preferred_match(self) -> None: + result = self._call([self.EN_US, self.EN_GB], preferred=self.EN_GB) + assert result == self.EN_GB + + def test_prefix_preferred_match(self) -> None: + result = self._call([self.EN_US], preferred="en_US") + assert result == self.EN_US + + def test_falls_back_to_hardcoded_default(self) -> None: + result = self._call([self.EN_US, self.FR_FR]) + assert result == self.EN_US + + def test_falls_back_to_utf8(self) -> None: + result = self._call([self.FR_FR]) + assert result == self.FR_FR + + def test_returns_first_when_no_utf8(self) -> None: + result = self._call(["C ISO-8859-1"]) + assert result == "C ISO-8859-1" diff --git a/tests/test_smithy_versions.py b/tests/test_smithy_versions.py new file mode 100644 index 0000000..cefe286 --- /dev/null +++ b/tests/test_smithy_versions.py @@ -0,0 +1,169 @@ +from collections.abc import Generator +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +import pytest + +from nullforge.smithy.versions import ( + DEFAULT_VERSIONS, + VERSION_MARKER_DIR, + Versions, + is_pinned_version_installed, + record_installed_version, +) + + +class _FakeData: + def __init__(self, overrides: dict | None = None) -> None: + self._overrides = overrides or {} + + def get(self, key: str, default: object = None) -> object: + return self._overrides.get(key, default) + + +@contextmanager +def _versions_context(arch: str = "x86_64", overrides: dict | None = None) -> Generator[Versions]: + host = MagicMock() + host.data = _FakeData(overrides) + host.get_fact.return_value = arch + with patch("nullforge.smithy.versions._ctx_host", host): + with patch("nullforge.smithy.arch.host", host): + yield Versions() + + +class TestVersionsDefaults: + def test_uses_default_versions(self) -> None: + with _versions_context() as v: + assert v.versions["blocky"] == DEFAULT_VERSIONS["blocky"] + + def test_host_overrides_merge(self) -> None: + with _versions_context(overrides={"versions": {"blocky": "v99.0"}}) as v: + assert v.versions["blocky"] == "v99.0" + + +class TestArchSelect: + def test_x86_64_selects_first(self) -> None: + with _versions_context("x86_64") as v: + assert v._arch_select("amd64", "arm64") == "amd64" + + def test_arm64_selects_second(self) -> None: + with _versions_context("arm64") as v: + assert v._arch_select("amd64", "arm64") == "arm64" + + def test_unknown_arch_raises(self) -> None: + with _versions_context("riscv64") as v: + with pytest.raises(ValueError, match="Unsupported architecture"): + v._arch_select("amd64", "arm64") + + +class TestVersionsUrls: + def test_cloudflared_url_amd64_on_x86(self) -> None: + with _versions_context("x86_64") as v: + url = v.cloudflared_url() + assert "amd64" in url + + def test_cloudflared_url_arm64_on_arm(self) -> None: + with _versions_context("arm64") as v: + url = v.cloudflared_url() + assert "arm64" in url + + def test_telemt_tar_arch_selection(self) -> None: + with _versions_context("x86_64") as v: + assert "telemt-x86_64-linux-gnu.tar.gz" in v.telemt_tar() + with _versions_context("arm64") as v: + assert "telemt-aarch64-linux-gnu.tar.gz" in v.telemt_tar() + + def test_telemt_tar_latest_uses_latest_path(self) -> None: + with _versions_context(overrides={"versions": {"telemt": "latest"}}) as v: + url = v.telemt_tar() + assert "/releases/latest/download/" in url + + def test_nvim_x86_is_appimage(self) -> None: + with _versions_context("x86_64") as v: + url = v.nvim_appimage() + assert url.endswith(".appimage") + + def test_nvim_arm64_is_tar_gz(self) -> None: + with _versions_context("arm64") as v: + url = v.nvim_appimage() + assert url.endswith(".tar.gz") + + +@contextmanager +def _pin_check_context( + *, + file_exists: bool = True, + command_output: str | None = None, + marker_lines: list[str] | None = None, + overrides: dict | None = None, +) -> Generator[MagicMock]: + host = MagicMock() + host.data = _FakeData(overrides) + + def _get_fact(fact: object, *args: object, **kwargs: object) -> object: + name = getattr(fact, "__name__", "") + if name == "File": + return {"mode": "755"} if file_exists else None + if name == "Command": + return command_output + if name == "FileContents": + return marker_lines + raise AssertionError(f"unexpected fact: {fact}") + + host.get_fact.side_effect = _get_fact + with patch("nullforge.smithy.versions._ctx_host", host): + yield host + + +class TestIsPinnedVersionInstalled: + def test_missing_binary_needs_install(self) -> None: + with _pin_check_context(file_exists=False): + assert is_pinned_version_installed("tmux", "/usr/local/bin/tmux") is False + + def test_matching_version_output(self) -> None: + with _pin_check_context(command_output=f"tmux {DEFAULT_VERSIONS['tmux']}"): + assert is_pinned_version_installed("tmux", "/usr/local/bin/tmux") is True + + def test_mismatched_version_needs_reinstall(self) -> None: + with _pin_check_context(command_output="tmux 3.0a"): + assert is_pinned_version_installed("tmux", "/usr/local/bin/tmux") is False + + def test_empty_version_output_needs_reinstall(self) -> None: + with _pin_check_context(command_output=None): + assert is_pinned_version_installed("eza", "/usr/local/bin/eza") is False + + def test_uses_tool_specific_command(self) -> None: + with _pin_check_context(command_output=f"tmux {DEFAULT_VERSIONS['tmux']}") as host: + is_pinned_version_installed("tmux", "/usr/local/bin/tmux") + commands = [ + call.args[1] for call in host.get_fact.call_args_list if getattr(call.args[0], "__name__", "") == "Command" + ] + assert commands == ["/usr/local/bin/tmux -V 2>&1 || true"] + + def test_latest_pin_skips_version_check(self) -> None: + with _pin_check_context(overrides={"versions": {"direnv": "latest"}}) as host: + assert is_pinned_version_installed("direnv", "/usr/local/bin/direnv") is True + assert all(getattr(call.args[0], "__name__", "") == "File" for call in host.get_fact.call_args_list) + + def test_marker_tool_matches_recorded_version(self) -> None: + with _pin_check_context(marker_lines=[DEFAULT_VERSIONS["wgcf"]]): + assert is_pinned_version_installed("wgcf", "/usr/local/bin/wgcf") is True + + def test_marker_tool_missing_marker_needs_reinstall(self) -> None: + with _pin_check_context(marker_lines=None): + assert is_pinned_version_installed("wgcf", "/usr/local/bin/wgcf") is False + + def test_marker_tool_outdated_marker_needs_reinstall(self) -> None: + with _pin_check_context(marker_lines=["0.0.1"]): + assert is_pinned_version_installed("wgcf", "/usr/local/bin/wgcf") is False + + +class TestRecordInstalledVersion: + def test_writes_marker_file(self) -> None: + with _pin_check_context(): + with patch("nullforge.smithy.versions.files") as files_mock: + record_installed_version("wgcf") + + kwargs = files_mock.put.call_args.kwargs + assert kwargs["dest"] == f"{VERSION_MARKER_DIR}/wgcf" + assert kwargs["src"].getvalue() == f"{DEFAULT_VERSIONS['wgcf']}\n" diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..031dda7 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,19 @@ +import pytest + +from nullforge.templates import get_template_path, render_template + + +class TestGetTemplatePath: + def test_raises_for_missing_template(self) -> None: + with pytest.raises(FileNotFoundError): + get_template_path("nonexistent/file.j2") + + +class TestRenderTemplate: + def test_renders_variables(self) -> None: + rendered = render_template("profiles/zshrc.j2", home="/root") + assert 'export ZSH="/root/.oh-my-zsh"' in rendered + + def test_raises_for_missing_template(self) -> None: + with pytest.raises(FileNotFoundError): + render_template("nonexistent/file.j2") diff --git a/tests/test_templates_telemt.py b/tests/test_templates_telemt.py new file mode 100644 index 0000000..2ff0cba --- /dev/null +++ b/tests/test_templates_telemt.py @@ -0,0 +1,108 @@ +import tomllib +from pathlib import Path + +from jinja2 import Environment, StrictUndefined + +from nullforge.templates import BLOCK_TRIM_ENV, get_template_path + + +def _render(template_name: str, **context: object) -> str: + source = Path(get_template_path(template_name)).read_text(encoding="utf-8") + # autoescape stays off (TOML/systemd, not HTML) to mirror pyinfra's renderer. + env = Environment( # noqa: S701 + undefined=StrictUndefined, + keep_trailing_newline=True, + **BLOCK_TRIM_ENV, + ) + return env.from_string(source).render(**context) + + +def _config(**overrides: object) -> dict: + context: dict[str, object] = { + "FAST_MODE": True, + "USE_MIDDLE_PROXY": False, + "MODE_CLASSIC": False, + "MODE_SECURE": False, + "MODE_TLS": True, + "MAX_CONNECTIONS": 16384, + "CLIENT_HANDSHAKE": 30, + "PORT": 443, + "API_ENABLED": False, + "IPV6": True, + "PREFER": 6, + "TLS_DOMAIN": "wlix13.dev", + "UNKNOWN_SNI_ACTION": None, + "USERS": {"personal": "bf777cca8384a074a671460d51e4e31f"}, + "ROUTE_VIA_WARP": True, + "WARP_IFACE": "warp", + } + context.update(overrides) + rendered = _render("telemt/telemt.toml.j2", **context) + return tomllib.loads(rendered) + + +class TestConfigTemplate: + def test_renders_valid_toml(self) -> None: + data = _config() + assert data["general"]["fast_mode"] is True + assert data["general"]["use_middle_proxy"] is False + assert data["general"]["log_level"] == "silent" + + def test_modes_are_booleans(self) -> None: + data = _config(MODE_CLASSIC=True, MODE_SECURE=False, MODE_TLS=True) + assert data["general"]["modes"] == {"classic": True, "secure": False, "tls": True} + + def test_users_rendered(self) -> None: + users = {"personal": "bf777cca8384a074a671460d51e4e31f", "guests": "fdca0c33825b644bb4f5b9740cf6538c"} + data = _config(USERS=users) + assert data["access"]["users"] == users + + def test_unknown_sni_commented_when_absent(self) -> None: + data = _config(UNKNOWN_SNI_ACTION=None) + assert "unknown_sni_action" not in data["censorship"] + assert data["censorship"]["tls_domain"] == "wlix13.dev" + + def test_unknown_sni_emitted_when_set(self) -> None: + data = _config(UNKNOWN_SNI_ACTION="mask") + assert data["censorship"]["unknown_sni_action"] == "mask" + + +def _service(**overrides: object) -> str: + context: dict[str, object] = { + "USER": "telemt", + "GROUP": "telemt", + "BINARY_PATH": "/usr/local/bin/telemt", + "CONFIG_PATH": "/etc/telemt/telemt.toml", + "PORT": 443, + "WARP_ENABLED": True, + "WARP_SERVICE": "cloudflare-warp", + "WARP_IFACE": "warp", + "TELEPROXY_SCRIPT": "/usr/local/sbin/teleproxy-warp.sh", + "SYNFIX_ENABLED": True, + "SYNFIX_SCRIPT": "/usr/local/sbin/telemt-synfix.sh", + } + context.update(overrides) + return _render("systemd/telemt.service.j2", **context) + + +class TestServiceTemplate: + def test_warp_wiring_present_when_enabled(self) -> None: + unit = _service(WARP_ENABLED=True) + assert "Requires=cloudflare-warp.service" in unit + assert "BindsTo=cloudflare-warp.service" in unit + assert "ExecStartPre=+/usr/local/sbin/teleproxy-warp.sh up telemt warp" in unit + assert "ExecStopPost=+/usr/local/sbin/teleproxy-warp.sh down telemt warp" in unit + + def test_warp_wiring_absent_when_disabled(self) -> None: + unit = _service(WARP_ENABLED=False) + assert "cloudflare-warp.service" not in unit + assert "teleproxy-warp.sh" not in unit + + def test_synfix_wiring_present_when_enabled(self) -> None: + unit = _service(SYNFIX_ENABLED=True, PORT=8443) + assert "ExecStartPre=+/usr/local/sbin/telemt-synfix.sh up 8443" in unit + assert "ExecStopPost=+/usr/local/sbin/telemt-synfix.sh down 8443" in unit + + def test_synfix_wiring_absent_when_disabled(self) -> None: + unit = _service(SYNFIX_ENABLED=False) + assert "telemt-synfix.sh" not in unit From ec70c14f2df16f33e5af53434b06f84d4c54b09c Mon Sep 17 00:00:00 2001 From: wlix13 Date: Sat, 25 Jul 2026 23:53:31 +0300 Subject: [PATCH 3/3] test(cli): add cli test suite --- tests/cli/__init__.py | 0 tests/cli/components/__init__.py | 0 tests/cli/components/completion/__init__.py | 0 .../components/completion/test_controller.py | 77 ++++++++++ .../components/completion/test_powershell.py | 97 ++++++++++++ tests/cli/components/foundry/__init__.py | 0 .../cli/components/foundry/test_controller.py | 141 ++++++++++++++++++ tests/cli/conftest.py | 20 +++ tests/cli/core/__init__.py | 0 tests/cli/core/test_application.py | 120 +++++++++++++++ tests/cli/test_types.py | 127 ++++++++++++++++ 11 files changed, 582 insertions(+) create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/components/__init__.py create mode 100644 tests/cli/components/completion/__init__.py create mode 100644 tests/cli/components/completion/test_controller.py create mode 100644 tests/cli/components/completion/test_powershell.py create mode 100644 tests/cli/components/foundry/__init__.py create mode 100644 tests/cli/components/foundry/test_controller.py create mode 100644 tests/cli/conftest.py create mode 100644 tests/cli/core/__init__.py create mode 100644 tests/cli/core/test_application.py create mode 100644 tests/cli/test_types.py diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/components/__init__.py b/tests/cli/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/components/completion/__init__.py b/tests/cli/components/completion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/components/completion/test_controller.py b/tests/cli/components/completion/test_controller.py new file mode 100644 index 0000000..1bd642c --- /dev/null +++ b/tests/cli/components/completion/test_controller.py @@ -0,0 +1,77 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from nullforge.cli.app import NullForgeCli, cli +from nullforge.cli.components.completion.errors import ProfileNotFound, UnsupportedShell +from nullforge.cli.core.errors import Unreachable + + +@pytest.fixture +def exposed_app(app: NullForgeCli) -> NullForgeCli: + app.cli_root = cli + return app + + +@pytest.fixture +def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(Path, "home", lambda: tmp_path) + return tmp_path + + +class TestScript: + def test_powershell_script(self, exposed_app: NullForgeCli) -> None: + script = exposed_app.completion.script("powershell") + + assert "Register-ArgumentCompleter" in script + assert "_NULLFORGE_COMPLETE" in script + + def test_zsh_script_uses_click_template(self, exposed_app: NullForgeCli) -> None: + script = exposed_app.completion.script("zsh") + + assert "_NULLFORGE_COMPLETE" in script + assert "nullforge" in script + + def test_unknown_shell_raises(self, exposed_app: NullForgeCli) -> None: + with pytest.raises(UnsupportedShell): + exposed_app.completion.script("klingon") + + def test_without_exposed_cli_raises(self, app: NullForgeCli) -> None: + with pytest.raises(Unreachable): + app.completion.script("powershell") + + +class TestInstall: + def test_powershell_install_is_idempotent(self, exposed_app: NullForgeCli, home: Path, tmp_path: Path) -> None: + profile = tmp_path / "profile" / "Microsoft.PowerShell_profile.ps1" + run_result = MagicMock(returncode=0, stdout=f"{profile}\n") + target = "nullforge.cli.components.completion.controller" + + with ( + patch(f"{target}.shutil.which", return_value="C:\\pwsh.exe"), + patch(f"{target}.subprocess.run", return_value=run_result), + ): + script_path, profile_path = exposed_app.completion.install("powershell") + exposed_app.completion.install("powershell") + + assert script_path == home / ".nullforge" / "completion.ps1" + assert "Register-ArgumentCompleter" in script_path.read_text(encoding="utf-8") + assert profile_path == profile + content = profile.read_text(encoding="utf-8") + assert content.count("# >>> nullforge completion >>>") == 1, "install must be idempotent" + assert f'. "{script_path}"' in content + + def test_fish_install_writes_autoload_file(self, exposed_app: NullForgeCli, home: Path) -> None: + script_path, profile_path = exposed_app.completion.install("fish") + + assert script_path == home / ".config" / "fish" / "completions" / "nullforge.fish" + assert profile_path == script_path + assert script_path.is_file() + + def test_powershell_missing_shell_raises(self, exposed_app: NullForgeCli, home: Path) -> None: + target = "nullforge.cli.components.completion.controller" + + with patch(f"{target}.shutil.which", return_value=None): + with pytest.raises(ProfileNotFound): + exposed_app.completion.install("powershell") diff --git a/tests/cli/components/completion/test_powershell.py b/tests/cli/components/completion/test_powershell.py new file mode 100644 index 0000000..601359a --- /dev/null +++ b/tests/cli/components/completion/test_powershell.py @@ -0,0 +1,97 @@ +import click +import pytest +from click.shell_completion import CompletionItem, get_completion_class + +from nullforge.cli.components.completion.powershell import ( + PowerShellComplete, + register_powershell_completion, + split_powershell_line, +) + + +def _completer() -> PowerShellComplete: + return PowerShellComplete(click.Group("nullforge"), {}, "nullforge", "_NULLFORGE_COMPLETE") + + +class TestSplitPowershellLine: + def test_plain_words(self) -> None: + assert split_powershell_line("nullforge cast -r warp") == ["nullforge", "cast", "-r", "warp"] + + def test_backslash_paths_survive(self) -> None: + assert split_powershell_line(r"nullforge cast -r D:\runes\cu") == [ + "nullforge", + "cast", + "-r", + r"D:\runes\cu", + ] + + def test_double_quoted_word_with_spaces(self) -> None: + assert split_powershell_line('nullforge cast -r "D:\\x y\\r.py"') == [ + "nullforge", + "cast", + "-r", + "D:\\x y\\r.py", + ] + + def test_single_quoted_word(self) -> None: + assert split_powershell_line("nullforge cast -i 'inv file.py'") == [ + "nullforge", + "cast", + "-i", + "inv file.py", + ] + + def test_unterminated_quote(self) -> None: + assert split_powershell_line('nullforge "abc') == ["nullforge", "abc"] + + +class TestGetCompletionArgs: + def test_mid_word(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("_NULLFORGE_COMPLETE_WORDS", "nullforge cast -r wa") + monkeypatch.setenv("_NULLFORGE_COMPLETE_INCOMPLETE", "wa") + + assert _completer().get_completion_args() == (["cast", "-r"], "wa") + + def test_trailing_space(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("_NULLFORGE_COMPLETE_WORDS", "nullforge cast -r") + monkeypatch.setenv("_NULLFORGE_COMPLETE_INCOMPLETE", "") + + assert _completer().get_completion_args() == (["cast", "-r"], "") + + def test_windows_path_incomplete_unmangled(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("_NULLFORGE_COMPLETE_WORDS", r"nullforge cast -r D:\runes\cu") + monkeypatch.setenv("_NULLFORGE_COMPLETE_INCOMPLETE", r"D:\runes\cu") + + assert _completer().get_completion_args() == (["cast", "-r"], r"D:\runes\cu") + + def test_missing_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("_NULLFORGE_COMPLETE_WORDS", raising=False) + monkeypatch.delenv("_NULLFORGE_COMPLETE_INCOMPLETE", raising=False) + + assert _completer().get_completion_args() == ([], "") + + +class TestFormatCompletion: + def test_with_help(self) -> None: + item = CompletionItem("warp", help="Cloudflare WARP deployment module.") + assert _completer().format_completion(item) == "plain\twarp\tCloudflare WARP deployment module." + + def test_without_help(self) -> None: + assert _completer().format_completion(CompletionItem("warp")) == "plain\twarp\t" + + +class TestSource: + def test_script_registers_both_command_names(self) -> None: + script = _completer().source() + + assert "Register-ArgumentCompleter -Native" in script + assert '"nullforge", "nullforge.exe"' in script + assert "_NULLFORGE_COMPLETE_WORDS" in script + assert "_NULLFORGE_COMPLETE_INCOMPLETE" in script + + +def test_registered_under_both_names() -> None: + register_powershell_completion() + + assert get_completion_class("powershell") is PowerShellComplete + assert get_completion_class("pwsh") is PowerShellComplete diff --git a/tests/cli/components/foundry/__init__.py b/tests/cli/components/foundry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/components/foundry/test_controller.py b/tests/cli/components/foundry/test_controller.py new file mode 100644 index 0000000..35acd8d --- /dev/null +++ b/tests/cli/components/foundry/test_controller.py @@ -0,0 +1,141 @@ +import json +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from nullforge.cli.app import NullForgeCli +from nullforge.cli.components.foundry.controller import CastOptions +from nullforge.cli.components.foundry.errors import PyinfraExecutionFailed, PyinfraLaunchFailed +from nullforge.foundry import FOUNDRY_DIR +from nullforge.runes import rune_path + + +RUN_TARGET = "nullforge.cli.components.foundry.controller.subprocess.run" + + +class TestResolveStages: + def test_no_runes_uses_full_cast(self, app: NullForgeCli) -> None: + assert app.foundry.resolve_stages((), False) == [(FOUNDRY_DIR / "full_cast.py", [])] + + def test_with_prepare_without_runes_bootstraps_full_cast(self, app: NullForgeCli) -> None: + stages = app.foundry.resolve_stages((), True) + + assert stages == [ + (FOUNDRY_DIR / "cast.py", [rune_path("prepare")]), + (FOUNDRY_DIR / "full_cast.py", []), + ] + + def test_selected_runes_dedupe_in_order(self, app: NullForgeCli) -> None: + warp, dns = rune_path("warp"), rune_path("dns") + + stages = app.foundry.resolve_stages((warp, dns, warp), False) + + assert stages == [(FOUNDRY_DIR / "cast.py", [warp, dns])] + + def test_with_prepare_becomes_separate_first_stage(self, app: NullForgeCli) -> None: + warp = rune_path("warp") + + stages = app.foundry.resolve_stages((warp,), True) + + assert stages == [ + (FOUNDRY_DIR / "cast.py", [rune_path("prepare")]), + (FOUNDRY_DIR / "cast.py", [warp]), + ] + + def test_with_prepare_does_not_duplicate_explicit_prepare(self, app: NullForgeCli) -> None: + prepare = rune_path("prepare") + + stages = app.foundry.resolve_stages((prepare,), True) + + assert stages == [(FOUNDRY_DIR / "cast.py", [prepare])] + + +class TestBuildArgv: + def test_full_cast_argv(self, app: NullForgeCli) -> None: + argv = app.foundry.build_argv("inv.py", FOUNDRY_DIR / "full_cast.py", [], CastOptions()) + + assert argv == [ + sys.executable, + "-m", + "nullforge.foundry._pyinfra", + "inv.py", + str(FOUNDRY_DIR / "full_cast.py"), + ] + + def test_all_option_kinds(self, app: NullForgeCli) -> None: + warp = rune_path("warp") + options = CastOptions( + dry=True, + verbosity=2, + ssh_user="root", + ssh_port=2222, + limit=("a", "b"), + data=("k=v",), + extra=("--serial",), + ) + + argv = app.foundry.build_argv("@local", FOUNDRY_DIR / "cast.py", [warp], options) + + assert argv == [ + sys.executable, + "-m", + "nullforge.foundry._pyinfra", + "@local", + str(FOUNDRY_DIR / "cast.py"), + "--dry", + "-v", + "-v", + "--ssh-user", + "root", + "--ssh-port", + "2222", + "--limit", + "a", + "--limit", + "b", + "--data", + "k=v", + "--data", + f"_nullforge_runes={json.dumps([str(warp)])}", + "--serial", + ] + + +class TestCast: + def test_runs_pyinfra_inheriting_stdio(self, app: NullForgeCli) -> None: + with patch(RUN_TARGET, return_value=MagicMock(returncode=0)) as run_mock: + app.foundry.cast("@local", (rune_path("base"),), False, CastOptions()) + + assert run_mock.call_count == 1 + assert run_mock.call_args.kwargs == {"check": False}, "stdio must be inherited, not captured" + + def test_nonzero_exit_raises_with_returncode(self, app: NullForgeCli) -> None: + with patch(RUN_TARGET, return_value=MagicMock(returncode=3)): + with pytest.raises(PyinfraExecutionFailed) as e: + app.foundry.cast("@local", (), False, CastOptions()) + + assert e.value.returncode == 3 + + def test_launch_failure_raises(self, app: NullForgeCli) -> None: + with patch(RUN_TARGET, side_effect=OSError("no python")): + with pytest.raises(PyinfraLaunchFailed): + app.foundry.cast("@local", (), False, CastOptions()) + + def test_with_prepare_full_cast_runs_prepare_then_full_cast(self, app: NullForgeCli) -> None: + with patch(RUN_TARGET, return_value=MagicMock(returncode=0)) as run_mock: + app.foundry.cast("@local", (), True, CastOptions()) + + assert run_mock.call_count == 2 + first_argv = run_mock.call_args_list[0].args[0] + second_argv = run_mock.call_args_list[1].args[0] + assert str(FOUNDRY_DIR / "cast.py") in first_argv + assert f"_nullforge_runes={json.dumps([str(rune_path('prepare'))])}" in first_argv + assert str(FOUNDRY_DIR / "full_cast.py") in second_argv + + def test_failed_prepare_stage_stops_the_cast(self, app: NullForgeCli) -> None: + with patch(RUN_TARGET, return_value=MagicMock(returncode=1)) as run_mock: + with pytest.raises(PyinfraExecutionFailed): + app.foundry.cast("@local", (rune_path("warp"),), True, CastOptions()) + + assert run_mock.call_count == 1, "the second stage must not run after a failed prepare" diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000..8b025e2 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,20 @@ +from collections.abc import Iterator + +import pytest + +from nullforge.cli.app import NullForgeCli +from nullforge.cli.core import BaseApplication + + +@pytest.fixture(autouse=True) +def _reset_singleton() -> Iterator[None]: + BaseApplication.reset() + NullForgeCli.reset() + yield + BaseApplication.reset() + NullForgeCli.reset() + + +@pytest.fixture +def app() -> NullForgeCli: + return NullForgeCli() diff --git a/tests/cli/core/__init__.py b/tests/cli/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/core/test_application.py b/tests/cli/core/test_application.py new file mode 100644 index 0000000..306088b --- /dev/null +++ b/tests/cli/core/test_application.py @@ -0,0 +1,120 @@ +from typing import Any, ClassVar + +import click +import pytest + +from nullforge.cli.core import BaseApplication, BaseComponent, BaseController +from nullforge.cli.core.errors import ComponentAlreadyRegistered, ComponentNotRegistered + + +class CountingController(BaseController[Any]): + pass + + +class CountingComponent(BaseComponent[Any, CountingController]): + name = "counting" + controller_class = CountingController + expose_controller = True + + registered: ClassVar[int] = 0 + deregistered: ClassVar[int] = 0 + + def on_register(self) -> None: + type(self).registered += 1 + + def on_deregister(self) -> None: + type(self).deregistered += 1 + + +def _counting_component() -> type[CountingComponent]: + """Fresh subclass per test: type(self) counters shadow the parent's zeros.""" + + class FreshCountingComponent(CountingComponent): + pass + + return FreshCountingComponent + + +def test_register_exposes_controller_and_fires_hook() -> None: + component_cls = _counting_component() + app = BaseApplication[Any]() + + app.register(component_cls) + + assert component_cls.registered == 1 + assert "counting" in app.components + assert isinstance(getattr(app, "counting"), CountingController) # noqa: B009 + + +def test_register_duplicate_name_raises() -> None: + component_cls = _counting_component() + app = BaseApplication[Any]() + app.register(component_cls) + + with pytest.raises(ComponentAlreadyRegistered): + app.register(component_cls) + + +def test_deregister_fires_hook_and_removes_controller() -> None: + component_cls = _counting_component() + app = BaseApplication[Any]() + app.register(component_cls) + + app.deregister("counting") + + assert component_cls.deregistered == 1 + assert not hasattr(app, "counting") + assert "counting" not in app.components + + +def test_deregister_unknown_raises() -> None: + app = BaseApplication[Any]() + + with pytest.raises(ComponentNotRegistered): + app.deregister("counting") + + +def test_current_requires_initialization() -> None: + with pytest.raises(RuntimeError): + BaseApplication.current() + + app = BaseApplication[Any]() + assert BaseApplication.current() is app + + +def test_reinitialization_replaces_singleton() -> None: + BaseApplication[Any]() + second = BaseApplication[Any]() + + assert BaseApplication.current() is second + + +def test_reset_clears_singleton() -> None: + BaseApplication[Any]() + + BaseApplication.reset() + + with pytest.raises(RuntimeError): + BaseApplication.current() + + +def test_register_cli_visits_default_components_in_order() -> None: + visited: list[str] = [] + + class FirstComponent(BaseComponent[Any, BaseController[Any]]): + name = "first" + controller_class = BaseController + + @classmethod + def expose_cli(cls, base: click.Group) -> None: + visited.append(cls.name) + + class SecondComponent(FirstComponent): + name = "second" + + class RecordingApp(BaseApplication["RecordingApp"]): + default_components = [FirstComponent, SecondComponent] + + RecordingApp.register_cli(click.Group("root")) + + assert visited == ["first", "second"] diff --git a/tests/cli/test_types.py b/tests/cli/test_types.py new file mode 100644 index 0000000..4f45262 --- /dev/null +++ b/tests/cli/test_types.py @@ -0,0 +1,127 @@ +from pathlib import Path + +import click +import pytest + +from nullforge.cli.types import ( + InventoryType, + KeyValueType, + RuneType, + ShellType, + complete_paths, +) +from nullforge.runes import RUNES_DIR + + +CTX = click.Context(click.Command("cast")) +PARAM = click.Option(["-x"]) + + +class TestInventoryType: + def test_existing_file_resolves_to_absolute(self, tmp_path: Path) -> None: + inventory = tmp_path / "inv.py" + inventory.write_text("hosts = []\n") + + result = InventoryType().convert(str(inventory), None, None) + + assert Path(result) == inventory.resolve() + + @pytest.mark.parametrize("spec", ["@local", "host1,host2", "203.0.113.10"]) + def test_raw_host_spec_passes_through(self, spec: str) -> None: + assert InventoryType().convert(spec, None, None) == spec + + def test_missing_path_fails(self) -> None: + with pytest.raises(click.UsageError): + InventoryType().convert("missing/inv.py", None, None) + + def test_shell_complete_offers_local(self) -> None: + items = InventoryType().shell_complete(CTX, PARAM, "@l") + assert [item.value for item in items] == ["@local"] + + +class TestRuneType: + def test_builtin_name_resolves(self) -> None: + assert RuneType().convert("warp", None, None) == RUNES_DIR / "warp.py" + + def test_custom_file_resolves(self, tmp_path: Path) -> None: + rune = tmp_path / "custom.py" + rune.write_text('"""Custom rune."""\n') + + assert RuneType().convert(str(rune), None, None) == rune.resolve() + + @pytest.mark.parametrize("value", ["nope", "_private", "./gone.py"]) + def test_invalid_values_fail(self, value: str) -> None: + with pytest.raises(click.UsageError): + RuneType().convert(value, None, None) + + def test_unknown_rune_error_lists_builtins(self) -> None: + with pytest.raises(click.UsageError, match="warp"): + RuneType().convert("nope", None, None) + + def test_shell_complete_returns_names_with_summaries(self) -> None: + items = RuneType().shell_complete(CTX, PARAM, "wa") + + assert [item.value for item in items] == ["warp"] + assert items[0].help, "Expected the rune docstring summary as completion help" + + def test_shell_complete_paths_when_path_like(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "my_rune.py").write_text('"""My rune."""\n') + monkeypatch.chdir(tmp_path) + + items = RuneType().shell_complete(CTX, PARAM, "sub/") + + assert [item.value for item in items] == ["sub/my_rune.py"] + + +class TestCompletePaths: + @pytest.fixture + def tree(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "inner.py").write_text("") + (tmp_path / "alpha.py").write_text("") + (tmp_path / "notes.txt").write_text("") + monkeypatch.chdir(tmp_path) + return tmp_path + + def test_lists_directories_first_and_filters_suffix(self, tree: Path) -> None: + values = [item.value for item in complete_paths("")] + assert values == ["sub/", "alpha.py"] + + def test_forward_slash_separator_preserved(self, tree: Path) -> None: + values = [item.value for item in complete_paths("sub/")] + assert values == ["sub/inner.py"] + + def test_backslash_separator_preserved(self, tree: Path) -> None: + values = [item.value for item in complete_paths("sub\\")] + assert values == ["sub\\inner.py"] + + def test_fragment_filters_entries(self, tree: Path) -> None: + values = [item.value for item in complete_paths("al")] + assert values == ["alpha.py"] + + def test_missing_directory_returns_empty(self, tree: Path) -> None: + assert complete_paths("gone/") == [] + + +class TestShellType: + @pytest.mark.parametrize( + ("value", "expected"), [("bash", "bash"), ("PowerShell", "powershell"), ("pwsh", "powershell")] + ) + def test_convert_normalizes(self, value: str, expected: str) -> None: + assert ShellType().convert(value, None, None) == expected + + def test_unsupported_shell_fails(self) -> None: + with pytest.raises(click.UsageError): + ShellType().convert("cmd", None, None) + + +class TestKeyValueType: + @pytest.mark.parametrize("value", ["k=v", "k=", "key=a=b"]) + def test_valid_pairs_pass_through(self, value: str) -> None: + assert KeyValueType().convert(value, None, None) == value + + @pytest.mark.parametrize("value", ["=v", "kv", ""]) + def test_invalid_pairs_fail(self, value: str) -> None: + with pytest.raises(click.UsageError): + KeyValueType().convert(value, None, None)