erplibre/test/test_qemu_forgejo.py
Mathieu Benoit 952c92b52a [ADD] script forgejo: installer une forge git en option cochable
Une case au déploiement, et une forge git auto-hébergée répond sur le port 3000,
git par SSH sur 2222. Le travail vit dans un script dédié, appelable seul sur
une machine existante : une seule autorité pour les deux usages.

Le binaire officiel est statique, donc le même fichier sert apt, dnf, pacman et
zypper — c'est ce qui rend l'option portable sans une branche par distribution.
Les architectures suivent l'amont (amd64, arm64, arm-6) ; la case se grise sur
s390x plutôt que de poser un binaire inexécutable. Les quatre secrets sont
écrits par le script : sans oauth2.JWT_SECRET, Forgejo tente de les persister
lui-même et boucle sur un app.ini qu'il n'a pas le droit d'écrire.

Vérifié sur une VM : somme de contrôle validée, service actif, API qui répond,
dépôt créé puis cloné par git, et relance en 1,5 s sans rien réécrire.

--- EN ---

One checkbox at deploy time, and a self-hosted git forge answers on port 3000,
git over SSH on 2222. The work lives in a dedicated script, callable on its own
for an existing machine: one authority for both uses.

The official binary is static, so the same file serves apt, dnf, pacman and
zypper — that is what makes the option portable without a branch per
distribution. Architectures follow upstream (amd64, arm64, arm-6); the checkbox
greys out on s390x rather than dropping a binary that cannot run. The script
writes all four secrets itself: without oauth2.JWT_SECRET, Forgejo tries to
persist them and loops on an app.ini it is not allowed to write.

Verified on a VM: checksum validated, service active, API answering, a repo
created then cloned over git, and a replay in 1.5 s rewriting nothing.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00

260 lines
10 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Option Forgejo : la case, son filtrage, et le script qui fait le travail.
Forgejo est un service, pas un outil de bureau : une VM serveur le prend comme
une VM graphique. Son binaire est statique, donc le même fichier sert apt, dnf,
pacman et zypper — c'est ce qui le rend portable sans une branche par
distribution. Les architectures, elles, sont bornées par l'amont : Forgejo
publie amd64, arm64 et arm-6, et rien pour s390x.
Le script est vérifié en l'EXÉCUTANT sur ses chemins de refus — architecture
inconnue, version introuvable — qui précèdent toute élévation de privilège et
ne touchent donc à rien.
"""
import os
import pathlib
import subprocess
import sys
import tempfile
import unittest
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
SCRIPT = (
pathlib.Path(__file__).resolve().parent.parent
/ "script/forgejo/install_forgejo.sh"
)
class TestTheCheckbox(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
self.spec = TODO._QEMU_VM_TOOLS["forgejo"]
def test_it_is_offered_in_the_form(self):
keys = [k for k, _l, _h in TODO._qemu_vm_tool_choices()]
self.assertIn("forgejo", keys)
def test_a_plain_server_gets_it(self):
"""Une forge n'affiche rien : elle n'a pas besoin de bureau."""
self.assertFalse(self.spec["needs_desktop"])
got = self.todo._qemu_tools_for(("forgejo",), "amd64", "", "ubuntu")
self.assertIn("forgejo", got)
def test_every_package_family_gets_it(self):
"""Le binaire est statique : aucune famille n'est exclue, à la
différence de la compilation mobile que son installateur borne à apt.
"""
self.assertEqual(self.spec["families"], ())
for distro in ("ubuntu", "debian", "almalinux", "opensuse", "arch"):
self.assertIn(
"forgejo",
self.todo._qemu_tools_for(("forgejo",), "amd64", "", distro),
distro,
)
def test_arm64_yes_s390x_no(self):
"""Forgejo publie amd64, arm64 et arm-6. Sur s390x il faudrait le bâtir
en Go : la case se grise plutôt que de poser un binaire inexécutable.
"""
for arch in ("amd64", "arm64"):
self.assertIn(
"forgejo",
self.todo._qemu_tools_for(("forgejo",), arch, "", "ubuntu"),
arch,
)
self.assertNotIn(
"forgejo",
self.todo._qemu_tools_for(("forgejo",), "s390x", "", "ubuntu"),
)
def test_its_disk_cost_is_counted_in_the_plan(self):
self.assertGreater(
self.todo._qemu_tools_disk_gb(("forgejo",), "amd64", "", "ubuntu"),
0,
)
class TestTheInstallBlock(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
def test_it_calls_the_dedicated_script(self):
"""Une seule autorité : la même commande sert le déploiement d'une VM et
une installation à la main."""
for prod, root in (
(False, "$HOME/git/erplibre"),
(True, "/opt/erplibre"),
):
block = self.todo._qemu_forgejo_steps(
self.todo._qemu_install_dir(prod)
)
self.assertIn(f"{root}/script/forgejo/install_forgejo.sh", block)
def test_alone_it_does_not_drag_the_android_prologue(self):
"""Cocher Forgejo seul ne doit pas installer un SDK Android."""
cmd = self.todo._qemu_after_remote_cmd(("forgejo",), False)
self.assertIn("install_forgejo.sh", cmd)
self.assertNotIn("sdkmanager", cmd)
self.assertNotIn("mstep", cmd)
def test_with_the_mobile_build_forgejo_comes_first(self):
"""Une minute contre une heure : un échec rapide se voit tôt."""
cmd = self.todo._qemu_after_remote_cmd(("forgejo", "mobile"), False)
self.assertLess(cmd.index("install_forgejo.sh"), cmd.index("gradlew"))
def test_a_forgejo_failure_still_fails_the_vm(self):
"""Même contrat que la compilation mobile : une VM dont la forge
demandée n'existe pas n'est pas la VM demandée. Les groupes sont donc
liés par « && », jamais par « ; »."""
cmd = self.todo._qemu_after_remote_cmd(("forgejo", "mobile"), False)
between = cmd[
cmd.index("install_forgejo.sh") : cmd.index("ERPLibre mobile")
]
self.assertIn("&&", between)
self.assertNotIn("|| true", between)
def test_valid_shell_in_every_combination(self):
for tools in (
("forgejo",),
("forgejo", "mobile"),
("forgejo", "avd"),
("forgejo", "mobile", "avd"),
):
cmd = self.todo._qemu_after_remote_cmd(tools, False)
res = subprocess.run(
["bash", "-n"],
input="mstep() { :; }; mdiag() { :; };\n" + cmd,
capture_output=True,
text=True,
)
self.assertEqual(0, res.returncode, f"{tools}: {res.stderr}")
class TestTheScript(unittest.TestCase):
"""Le script lui-même, exécuté sur ses chemins de refus."""
def test_it_is_executable_and_valid_shell(self):
self.assertTrue(os.access(SCRIPT, os.X_OK), "pas exécutable")
res = subprocess.run(
["bash", "-n", str(SCRIPT)], capture_output=True, text=True
)
self.assertEqual(0, res.returncode, res.stderr)
def test_help_explains_the_knobs_and_exits_clean(self):
res = subprocess.run(
["bash", str(SCRIPT), "--help"], capture_output=True, text=True
)
self.assertEqual(0, res.returncode, res.stderr)
for knob in (
"FORGEJO_VERSION",
"FORGEJO_HTTP_PORT",
"FORGEJO_ADMIN_USER",
):
self.assertIn(knob, res.stdout)
def _run_with_stubs(self, stubs, env=None):
"""Lance le script avec un PATH bouchonné. Les chemins testés ici
s'arrêtent AVANT tout sudo : rien n'est installé nulle part."""
with tempfile.TemporaryDirectory() as tmp:
bin_dir = pathlib.Path(tmp) / "bin"
bin_dir.mkdir()
for name, body in stubs.items():
(bin_dir / name).write_text(f"#!/bin/bash\n{body}\n")
(bin_dir / name).chmod(0o755)
return subprocess.run(
["bash", str(SCRIPT)],
capture_output=True,
text=True,
env=dict(
os.environ,
PATH=f"{bin_dir}:/usr/bin:/bin",
**(env or {}),
),
timeout=120,
)
def test_an_unpublished_architecture_is_refused_by_name(self):
"""Sur s390x, Forgejo n'a pas de binaire. Le dire vaut mieux que
télécharger un fichier qui ne s'exécutera pas."""
res = self._run_with_stubs({"uname": "echo s390x"})
self.assertNotEqual(0, res.returncode)
self.assertIn("s390x", res.stdout + res.stderr)
def test_an_unreachable_release_feed_is_named(self):
res = self._run_with_stubs({"uname": "echo x86_64", "curl": "exit 7"})
self.assertNotEqual(0, res.returncode)
self.assertIn("Version", res.stdout + res.stderr)
def test_a_pinned_version_needs_no_feed(self):
"""FORGEJO_VERSION évite l'appel réseau : utile hors ligne, et c'est ce
qui rend ce test rapide."""
res = self._run_with_stubs(
{"uname": "echo x86_64", "curl": "exit 7", "sudo": "exit 0"},
env={"FORGEJO_VERSION": "9.9.9"},
)
out = res.stdout + res.stderr
self.assertIn("9.9.9", out)
# Il échoue plus loin (le téléchargement est bouchonné), pas sur la
# version : c'est bien le réseau du flux qui a été évité.
self.assertNotIn("Version de Forgejo introuvable", out)
class TestTheScriptGuards(unittest.TestCase):
"""Quatre pièges rencontrés en le mettant au point, tous mesurés."""
def setUp(self):
self.body = SCRIPT.read_text()
@property
def code_lines(self):
"""Les lignes de CODE : le piège est expliqué en commentaire, et un
test qui cherche dans les commentaires trébuche sur sa propre
documentation — vécu à l'écriture de ce fichier."""
return [
ln
for ln in self.body.splitlines()
if ln.strip() and not ln.lstrip().startswith("#")
]
def test_it_never_probes_with_exec_on_dev_tcp(self):
"""« exec » est un builtin spécial : une redirection qui échoue termine
le shell. Le script mourait au premier tour de la boucle d'attente,
code 1 et pas un mot."""
guilty = [ln for ln in self.code_lines if "exec 3<>" in ln]
self.assertEqual([], guilty)
self.assertIn("/api/v1/version", self.body)
def test_the_config_test_goes_through_sudo(self):
"""/etc/forgejo est en 770 root:git : « [ -f ] » échouait toujours, et
chaque passage réécrivait la configuration avec des secrets neufs."""
self.assertIn("sudo test -f", self.body)
def test_all_four_secrets_are_written(self):
"""Sans oauth2.JWT_SECRET, Forgejo tente de l'écrire dans app.ini,
n'y arrive pas, et boucle sur « [F] save oauth2.JWT_SECRET failed »."""
for key in (
"SECRET_KEY",
"INTERNAL_TOKEN",
"JWT_SECRET",
"LFS_JWT_SECRET",
):
self.assertIn(key, self.body, key)
def test_the_default_admin_name_is_not_reserved(self):
"""Forgejo refuse « admin » : « CreateUser: name is reserved »."""
self.assertIn("FORGEJO_ADMIN_USER:-erplibre", self.body)
self.assertNotIn("FORGEJO_ADMIN_USER:-admin}", self.body)
def test_it_touches_no_package_manager(self):
"""C'est ce qui le rend portable : le binaire est statique."""
for pm in ("apt-get install", "dnf install", "pacman -S", "zypper"):
self.assertNotIn(pm, self.body, pm)
if __name__ == "__main__":
unittest.main()