Les outils de la phase « après » vivent DANS le dépôt : la compilation mobile, l'AVD, et maintenant le script Forgejo. Sur une VM « bureau seul », sans clone, ils n'existent pas — et ils étaient écartés en silence. Une case cochée passait donc pour honorée. La commande le dit désormais, en nommant les outils concernés. Trois lignes qui évitent de chercher pourquoi la forge demandée n'est nulle part. --- EN --- The "after" phase tools live IN the repository: the mobile build, the AVD, and now the Forgejo script. On a desktop-only VM, with no clone, they do not exist — and they were dropped in silence. A ticked checkbox therefore passed for honoured. The command now says so, naming the tools concerned. Three lines that save hunting for why the requested forge is nowhere to be found. Assisted-by: Claude Opus 5
349 lines
14 KiB
Python
349 lines
14 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 TestDesktopOnlyVm(unittest.TestCase):
|
|
"""Une VM sans ERPLibre : le script Forgejo vit dans le dépôt, donc nulle
|
|
part. L'écarter en silence laisserait croire qu'une case cochée a été
|
|
honorée."""
|
|
|
|
def setUp(self):
|
|
self.todo = TODO.__new__(TODO)
|
|
|
|
def test_it_says_what_it_skips(self):
|
|
cmd = self.todo._qemu_erplibre_remote_cmd(
|
|
None, None, False, "gnome", "", "deb", ("forgejo",)
|
|
)
|
|
self.assertIn("forgejo", cmd)
|
|
self.assertIn("⚠", cmd)
|
|
self.assertNotIn("install_forgejo.sh", cmd)
|
|
|
|
def test_it_stays_quiet_when_nothing_was_deferred(self):
|
|
cmd = self.todo._qemu_erplibre_remote_cmd(
|
|
None, None, False, "gnome", "", "deb", ("gnome_ext",)
|
|
)
|
|
self.assertNotIn("⚠", cmd)
|
|
|
|
def test_the_note_is_valid_shell(self):
|
|
cmd = self.todo._qemu_erplibre_remote_cmd(
|
|
None, None, False, "gnome", "", "deb", ("forgejo", "mobile")
|
|
)
|
|
res = subprocess.run(
|
|
["bash", "-n"], input=cmd, capture_output=True, text=True
|
|
)
|
|
self.assertEqual(0, res.returncode, 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 TestHostAddress(unittest.TestCase):
|
|
"""L'adresse qui va dans ROOT_URL et SSH_DOMAIN, sur trois terrains.
|
|
|
|
« hostname -I » vient de net-tools : l'inetutils d'Arch ne connaît pas ce
|
|
drapeau et peut rendre le NOM de la machine. Une ROOT_URL bâtie sur un nom
|
|
non résolvable est pire qu'un repli, d'où la validation de la forme.
|
|
"""
|
|
|
|
def _host_address(self, stubs):
|
|
"""Extrait la fonction du script et l'exécute avec un PATH bouchonné."""
|
|
body = SCRIPT.read_text()
|
|
start = body.index("host_address() {")
|
|
end = body.index("\n}", start) + 2
|
|
fn = body[start:end]
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
bin_dir = pathlib.Path(tmp) / "bin"
|
|
bin_dir.mkdir()
|
|
for name, script in stubs.items():
|
|
(bin_dir / name).write_text(f"#!/bin/bash\n{script}\n")
|
|
(bin_dir / name).chmod(0o755)
|
|
res = subprocess.run(
|
|
["bash", "-c", fn + "\nhost_address"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"),
|
|
timeout=30,
|
|
)
|
|
return res.stdout.strip()
|
|
|
|
def test_it_takes_the_address_hostname_gives(self):
|
|
got = self._host_address({"hostname": "echo 10.1.2.3"})
|
|
self.assertEqual("10.1.2.3", got)
|
|
|
|
def test_a_hostname_that_returns_a_name_is_rejected(self):
|
|
"""Le cas Arch : on tombe alors sur « ip », et non sur un nom."""
|
|
got = self._host_address(
|
|
{
|
|
"hostname": "echo erplibre-arch",
|
|
"ip": "echo '1.0.0.1 via 10.0.0.1 dev eth0 src 10.9.9.9 uid 0'",
|
|
}
|
|
)
|
|
self.assertEqual("10.9.9.9", got)
|
|
|
|
def test_without_hostname_nor_ip_it_falls_back_to_localhost(self):
|
|
"""Une forge joignable en local vaut mieux qu'un script qui s'arrête."""
|
|
got = self._host_address({"hostname": "exit 1", "ip": "exit 1"})
|
|
self.assertEqual("localhost", got)
|
|
|
|
def test_it_never_returns_an_empty_string(self):
|
|
"""Une ROOT_URL « http://:3000/ » ne mène nulle part."""
|
|
for stubs in (
|
|
{"hostname": "echo", "ip": "echo"},
|
|
{"hostname": "exit 2", "ip": "exit 2"},
|
|
):
|
|
self.assertTrue(self._host_address(stubs), stubs)
|
|
|
|
|
|
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()
|