test_slicer_cert_installer.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. """The slicer certificate scripts (Settings -> Virtual Printer).
  2. The bash script runs for real against a folder laid out like a slicer
  3. install. The PowerShell part of the Windows script runs the same way when
  4. ``pwsh`` is installed (GitHub's Ubuntu runners have it); what needs Windows
  5. itself (administrator rights, the scheduled task) stays out of the test run.
  6. """
  7. import datetime
  8. import shutil
  9. import subprocess
  10. from pathlib import Path
  11. import pytest
  12. from cryptography import x509
  13. from cryptography.hazmat.primitives import hashes, serialization
  14. from cryptography.hazmat.primitives.asymmetric import ec
  15. from cryptography.x509.oid import NameOID
  16. from backend.app.services.virtual_printer.slicer_cert_installer import build_slicer_cert_installer
  17. CA_NAME = "Virtual Printer CA TEST1234"
  18. FINGERPRINT = ":".join(["AB", "CD", "EF", "01"] * 8)
  19. def _make_cert(common_name: str) -> str:
  20. key = ec.generate_private_key(ec.SECP256R1())
  21. name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
  22. now = datetime.datetime.now(datetime.timezone.utc)
  23. cert = (
  24. x509.CertificateBuilder()
  25. .subject_name(name)
  26. .issuer_name(name)
  27. .public_key(key.public_key())
  28. .serial_number(x509.random_serial_number())
  29. .not_valid_before(now)
  30. .not_valid_after(now + datetime.timedelta(days=30))
  31. .sign(key, hashes.SHA256())
  32. )
  33. return cert.public_bytes(serialization.Encoding.PEM).decode("ascii")
  34. def _body(pem: str) -> str:
  35. return "".join(line for line in pem.strip().splitlines() if "-----" not in line)
  36. def _bodies(path: Path) -> list[str]:
  37. text = path.read_text().replace("\r", "")
  38. return [_body(chunk) for chunk in text.split("-----BEGIN CERTIFICATE-----")[1:]]
  39. @pytest.fixture(scope="module")
  40. def certs() -> dict[str, str]:
  41. return {
  42. "ours": _make_cert(CA_NAME),
  43. "ours_before_reset": _make_cert(CA_NAME),
  44. "stock1": _make_cert("BBL CA"),
  45. "stock2": _make_cert("BBL CA2 RSA"),
  46. }
  47. def _slicer(root: Path, name: str, content: str) -> Path:
  48. path = root / name / "resources" / "cert" / "printer.cer"
  49. path.parent.mkdir(parents=True)
  50. path.write_text(content, newline="")
  51. return path
  52. class TestBuild:
  53. def test_windows_script_has_the_ca_and_crlf_line_ends(self, certs):
  54. installer = build_slicer_cert_installer("windows", certs["ours"], FINGERPRINT)
  55. assert installer.filename == "bambuddy-slicer-cert.cmd"
  56. assert _body(certs["ours"])[:60] in installer.content
  57. assert FINGERPRINT in installer.content
  58. assert "__BAMBUDDY_" not in installer.content
  59. assert "\n" not in installer.content.replace("\r\n", "")
  60. def test_unix_script_has_the_ca_and_lf_line_ends(self, certs):
  61. installer = build_slicer_cert_installer("unix", certs["ours"], FINGERPRINT)
  62. assert installer.filename == "bambuddy-slicer-cert.sh"
  63. assert _body(certs["ours"])[:60] in installer.content
  64. assert "__BAMBUDDY_" not in installer.content
  65. assert "\r" not in installer.content
  66. def test_unknown_platform(self, certs):
  67. with pytest.raises(KeyError):
  68. build_slicer_cert_installer("amiga", certs["ours"], FINGERPRINT)
  69. def _runner(kind: str, tmp_path: Path, pem: str):
  70. """Return a function that runs the script once over ``tmp_path / 'apps'``."""
  71. content = build_slicer_cert_installer(kind, pem, FINGERPRINT).content
  72. if kind == "unix":
  73. script = tmp_path / "bambuddy-slicer-cert.sh"
  74. script.write_text(content)
  75. cmd = ["bash", str(script)]
  76. separator = " "
  77. else:
  78. # The .cmd wrapper only runs on Windows; the PowerShell part after
  79. # its marker is what does the work.
  80. script = tmp_path / "bambuddy-slicer-cert.ps1"
  81. script.write_text(content[content.index("#BAMBUDDY_POWERSHELL") :], newline="")
  82. cmd = [shutil.which("pwsh") or "pwsh", "-NoProfile", "-NonInteractive", "-File", str(script)]
  83. separator = ";"
  84. def run(*roots: Path) -> subprocess.CompletedProcess:
  85. return subprocess.run(
  86. cmd,
  87. capture_output=True,
  88. text=True,
  89. timeout=120,
  90. env={
  91. "PATH": "/usr/bin:/bin:/usr/local/bin",
  92. "HOME": str(tmp_path),
  93. "BAMBUDDY_CERT_TEST_ROOTS": separator.join(map(str, roots)),
  94. },
  95. )
  96. return run
  97. KINDS = [
  98. pytest.param(
  99. "unix",
  100. marks=pytest.mark.skipif(
  101. not (shutil.which("bash") and shutil.which("openssl")), reason="needs bash and openssl"
  102. ),
  103. ),
  104. pytest.param("windows", marks=pytest.mark.skipif(not shutil.which("pwsh"), reason="needs pwsh")),
  105. ]
  106. @pytest.mark.parametrize("kind", KINDS)
  107. class TestScripts:
  108. def test_adds_the_ca_keeps_the_rest_and_a_backup(self, kind, tmp_path, certs):
  109. apps = tmp_path / "apps"
  110. stock = certs["stock1"] + certs["stock2"]
  111. studio = _slicer(apps, "Bambu Studio", stock)
  112. orca = _slicer(apps, "OrcaSlicer", stock)
  113. result = _runner(kind, tmp_path, certs["ours"])(apps)
  114. assert result.returncode == 0, result.stdout + result.stderr
  115. for path in (studio, orca):
  116. assert _bodies(path) == [_body(certs["stock1"]), _body(certs["stock2"]), _body(certs["ours"])]
  117. assert Path(f"{path}.before-bambuddy").read_text() == stock
  118. assert not Path(f"{path}.bambuddy-new").exists()
  119. assert "added" in result.stdout
  120. def test_a_second_run_changes_nothing(self, kind, tmp_path, certs):
  121. apps = tmp_path / "apps"
  122. studio = _slicer(apps, "Bambu Studio", certs["stock1"])
  123. run = _runner(kind, tmp_path, certs["ours"])
  124. run(apps)
  125. after_first = studio.read_bytes()
  126. result = run(apps)
  127. assert result.returncode == 0, result.stdout + result.stderr
  128. assert studio.read_bytes() == after_first
  129. assert "already there" in result.stdout
  130. def test_an_older_certificate_of_this_bambuddy_is_replaced(self, kind, tmp_path, certs):
  131. # Same name, other key: a slicer would take the first and fail.
  132. apps = tmp_path / "apps"
  133. studio = _slicer(apps, "Bambu Studio", certs["stock1"] + certs["ours_before_reset"] + certs["stock2"])
  134. result = _runner(kind, tmp_path, certs["ours"])(apps)
  135. assert result.returncode == 0, result.stdout + result.stderr
  136. assert _bodies(studio) == [_body(certs["stock1"]), _body(certs["stock2"]), _body(certs["ours"])]
  137. assert "replaced" in result.stdout
  138. def test_another_install_with_the_bare_legacy_name_is_kept(self, kind, tmp_path, certs):
  139. # Every Bambuddy before per-install names called its CA just
  140. # "Virtual Printer CA": that one may be another instance's.
  141. ours = _make_cert("Virtual Printer CA")
  142. other = _make_cert("Virtual Printer CA")
  143. apps = tmp_path / "apps"
  144. studio = _slicer(apps, "Bambu Studio", certs["stock1"] + other)
  145. result = _runner(kind, tmp_path, ours)(apps)
  146. assert result.returncode == 0, result.stdout + result.stderr
  147. assert _bodies(studio) == [_body(certs["stock1"]), _body(other), _body(ours)]
  148. def test_a_file_with_crlf_and_no_final_newline(self, kind, tmp_path, certs):
  149. apps = tmp_path / "apps"
  150. studio = _slicer(apps, "Bambu Studio", certs["stock1"].strip().replace("\n", "\r\n"))
  151. result = _runner(kind, tmp_path, certs["ours"])(apps)
  152. assert result.returncode == 0, result.stdout + result.stderr
  153. assert _bodies(studio) == [_body(certs["stock1"]), _body(certs["ours"])]
  154. text = studio.read_text().replace("\r", "")
  155. assert "-----END CERTIFICATE----------BEGIN" not in text
  156. def test_no_slicer_found(self, kind, tmp_path, certs):
  157. (tmp_path / "apps").mkdir()
  158. result = _runner(kind, tmp_path, certs["ours"])(tmp_path / "apps")
  159. assert result.returncode == 2
  160. assert "No Bambu Studio or OrcaSlicer" in result.stdout