Dotfiles_ArchAP/greetd/tests/test_greeter.py

580 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import contextlib
import io
import importlib.util
import json
import os
from pathlib import Path
import pwd
import socket
import struct
import sys
import tempfile
import time
from unittest.mock import patch
import threading
import unittest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from auth import AuthFlow
from greetd_ipc import Client, ProtocolError, session_command, SESSION_COMMANDS, MAX_FRAME
from accounts import users
from system_state import battery, backlight, set_brightness
from avatar_backend import AvatarError, render_png, source_path
from avatar_icon import system_icon
STABLE = ["/usr/bin/uwsm", "start", "sway.desktop"]
MOSAIC = ["/usr/bin/uwsm", "start", "swayfx-mosaic-test.desktop"]
SECRET = "LABFY_SYNTHETIC_SECRET_59f3c4e2"
class FakeGreetd:
def __init__(self, replies):
self.temp = tempfile.TemporaryDirectory()
self.path = self.temp.name + "/socket"
self.server = socket.socket(socket.AF_UNIX)
self.server.bind(self.path)
self.server.listen(1)
self.server.settimeout(3)
self.failure = None
self.replies = replies
self.requests = []
self.thread = threading.Thread(target=self.run, daemon=True)
self.thread.start()
def run(self):
try:
connection, _ = self.server.accept()
connection.settimeout(3)
with connection:
for reply in self.replies:
header = self.read(connection, 4)
if not header:
break
size = struct.unpack("=I", header)[0]
assert size <= MAX_FRAME
request = json.loads(self.read(connection, size))
self.requests.append(request)
# None simule la perte du socket après réception de la requête.
if reply is None:
break
if callable(reply):
reply = reply()
encoded = json.dumps(reply).encode()
connection.sendall(struct.pack("=I", len(encoded)) + encoded)
except Exception as exc:
self.failure = exc
@staticmethod
def read(connection, count):
data = b""
while len(data) < count:
part = connection.recv(count - len(data))
if not part:
return data
data += part
return data
def close(self):
self.server.close()
self.thread.join(2)
self.temp.cleanup()
if self.thread.is_alive():
raise AssertionError("Serveur fictif encore actif")
if self.failure:
raise self.failure
def msg(kind, value="Message PAM"):
return {"type": "auth_message", "auth_message_type": kind, "auth_message": value}
class Tests(unittest.TestCase):
def test_full_pam_sequence_and_secret_absent_from_outputs(self):
fake = FakeGreetd([msg("secret"), msg("info"), msg("visible"), msg("error"), msg("secret"),
{"type": "success"}, {"type": "success"}])
output = io.StringIO()
try:
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
flow = AuthFlow(fake.path)
self.assertEqual(flow.begin("example")["auth_message_type"], "secret")
self.assertEqual(flow.answer(SECRET)["auth_message_type"], "info")
self.assertEqual(flow.answer()["auth_message_type"], "visible")
self.assertEqual(flow.answer("code")["auth_message_type"], "error")
self.assertEqual(flow.answer()["auth_message_type"], "secret")
self.assertEqual(flow.answer("second")["type"], "success")
self.assertEqual(flow.start()["type"], "success")
self.assertNotIn(SECRET, output.getvalue())
self.assertEqual(fake.requests[1]["response"], SECRET)
self.assertNotIn("response", fake.requests[2])
self.assertEqual(fake.requests[-1]["cmd"], STABLE)
finally:
fake.close()
def test_auth_error_retry(self):
first = FakeGreetd([msg("secret"), {"type": "error", "error_type": "auth_error", "description": "bad"}, {"type": "success"}])
second = FakeGreetd([msg("visible"), {"type": "success"}, {"type": "success"}])
try:
flow = AuthFlow(first.path)
flow.begin("example")
self.assertEqual(flow.answer("wrong")["error_type"], "auth_error")
self.assertFalse(flow.authenticated)
flow.socket_path = second.path
self.assertEqual(flow.begin("manual-nss-user")["auth_message_type"], "visible")
self.assertEqual(flow.answer("code")["type"], "success")
self.assertEqual(flow.start()["type"], "success")
self.assertEqual(second.requests[0]["username"], "manual-nss-user")
finally:
first.close()
second.close()
def test_mosaic_multi_prompt_secret_and_info(self):
fake = FakeGreetd([msg("info"), msg("visible"), msg("secret"),
{"type": "success"}, {"type": "success"}])
output = io.StringIO()
try:
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
flow = AuthFlow(fake.path)
flow.begin("example", "mosaic")
flow.answer()
flow.answer("visible-code")
flow.answer(SECRET)
flow.start()
self.assertNotIn(SECRET, output.getvalue())
self.assertEqual(fake.requests[-1], {"type": "start_session", "cmd": MOSAIC, "env": []})
finally:
fake.close()
def test_exact_commands_and_empty_environment(self):
for selection, expected in (("stable", STABLE), ("mosaic", MOSAIC), (None, STABLE)):
with self.subTest(selection=selection):
fake = FakeGreetd([{"type": "success"}, {"type": "success"}])
try:
flow = AuthFlow(fake.path)
if selection is None:
flow.begin("example")
else:
flow.begin("example", selection)
self.assertEqual(flow.start(), {"type": "success"})
self.assertEqual(fake.requests[-1], {"type": "start_session", "cmd": expected, "env": []})
with self.assertRaises(ProtocolError):
flow.start()
self.assertFalse(flow.authenticated)
finally:
fake.close()
def test_allowlist_rejects_types_and_injection_before_ipc(self):
invalid = (None, True, False, 0, 1, [], ["stable"], {}, "/usr/bin/sway",
"sway.desktop", "default", "stable ", "Stable", "mosaic --help",
"stable;sh", "$(id)", "stable\n", "stаble", "mosaic", "mosaic\x00")
from unittest.mock import patch
with patch.object(Client, "request") as request:
client = object.__new__(Client)
for choice in invalid:
with self.subTest(choice=choice):
with self.assertRaises(ValueError):
client.start(choice)
with self.assertRaises(ValueError):
AuthFlow("/missing").begin("example", choice)
with self.assertRaises(TypeError):
client.start("stable", "extra")
with self.assertRaises(TypeError):
client.start("stable", env=["HOME=/bad"])
with self.assertRaises(TypeError):
session_command("stable", "extra")
with self.assertRaises(TypeError):
AuthFlow().start("mosaic")
request.assert_not_called()
self.assertEqual(session_command(), tuple(STABLE))
with self.assertRaises(TypeError):
SESSION_COMMANDS["stable"] = tuple(MOSAIC)
with self.assertRaises(TypeError):
session_command()[0] = "sh"
def test_changed_identity_cancels_previous_conversation(self):
# Le vrai begin réinitialise PAM, même si seul le choix de session change.
for username in ("example", "other"):
first = FakeGreetd([msg("secret"), {"type": "success"}])
second = FakeGreetd([msg("secret"), {"type": "success"}, {"type": "success"}])
try:
flow = AuthFlow(first.path)
flow.begin("example", "mosaic")
flow.socket_path = second.path
flow.begin(username, "stable")
self.assertEqual(first.requests[-1], {"type": "cancel_session"})
self.assertEqual(flow.selection, "stable")
self.assertEqual(flow.username, username)
flow.answer(SECRET)
flow.start()
self.assertEqual(second.requests[-1]["cmd"], STABLE)
finally:
first.close()
second.close()
def test_launch_refusal_never_falls_back_and_consumes_auth(self):
fake = FakeGreetd([{"type": "success"},
{"type": "error", "error_type": "error", "description": "missing desktop"},
{"type": "success"}])
try:
flow = AuthFlow(fake.path)
flow.begin("example", "mosaic")
self.assertEqual(flow.start()["type"], "error")
self.assertIsNone(flow.client)
with self.assertRaises(ProtocolError):
flow.start()
launches = [r for r in fake.requests if r["type"] == "start_session"]
self.assertEqual(launches, [{"type": "start_session", "cmd": MOSAIC, "env": []}])
finally:
fake.close()
def test_v2_launch_refusal_then_fresh_stable_authentication(self):
# Un refus Mosaic consomme PAM ; Stable nécessite une nouvelle conversation.
first = FakeGreetd([msg("secret"), {"type": "success"},
{"type": "error", "error_type": "error"}, {"type": "success"}])
second = FakeGreetd([msg("secret"), {"type": "success"}, {"type": "success"}])
try:
flow = AuthFlow(first.path)
flow.begin("example", "mosaic")
flow.answer(SECRET)
self.assertEqual(flow.start()["type"], "error")
self.assertIsNone(flow.selection)
flow.socket_path = second.path
flow.begin("example", "stable")
self.assertFalse(flow.authenticated)
flow.answer(SECRET)
flow.start()
self.assertEqual(first.requests[-2], {"type": "start_session", "cmd": MOSAIC, "env": []})
self.assertEqual(second.requests[-1], {"type": "start_session", "cmd": STABLE, "env": []})
finally:
first.close()
second.close()
def test_socket_loss_resets_every_stage(self):
for replies, operation in (([None], "begin"), ([msg("secret"), None], "answer"),
([{"type": "success"}, None], "start")):
fake = FakeGreetd(replies)
try:
flow = AuthFlow(fake.path)
with self.assertRaises((ProtocolError, OSError)):
flow.begin("example", "mosaic")
if operation == "answer":
flow.answer(SECRET)
elif operation == "start":
flow.start()
self.assertIsNone(flow.client)
self.assertIsNone(flow.selection)
self.assertIsNone(flow.username)
self.assertFalse(flow.authenticated)
finally:
fake.close()
def test_unknown_user_error_and_invalid_username_reset(self):
fake = FakeGreetd([{"type": "error", "error_type": "auth_error"}, {"type": "success"}])
try:
flow = AuthFlow(fake.path)
self.assertEqual(flow.begin("unknown", "mosaic")["type"], "error")
self.assertIsNone(flow.client)
self.assertIsNone(flow.selection)
for value in (True, [], "", "bad\nname"):
with self.assertRaises(ValueError):
flow.begin(value)
finally:
fake.close()
def test_accounts(self):
Row = pwd.struct_passwd
entries = [Row(("system", "x", 999, 1, "", "/", "/bin/bash")),
Row(("human", "x", 1000, 1, "", "/", "/bin/bash")),
Row(("blocked", "x", 1001, 1, "", "/", "/usr/bin/nologin")),
Row(("false", "x", 1002, 1, "", "/", "/bin/false")),
Row(("another", "x", 1003, 1, "Example", "/", "/bin/zsh"))]
self.assertEqual(users(entries, (1000, 60000)), ["another", "human"])
def test_battery_and_backlight(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
self.assertIsNone(battery(root))
bat = root / "BAT1"
bat.mkdir()
(bat / "capacity").write_text("60")
for status in ("Charging", "Discharging", "Full", "Not charging"):
(bat / "status").write_text(status)
self.assertEqual(battery(root), (60, status))
light = root / "backlight" / "amdgpu_bl1"
light.mkdir(parents=True)
(light / "max_brightness").write_text("400000")
(light / "brightness").write_text("200000")
self.assertEqual(backlight(root / "backlight"), (light, 400000, 200000))
set_brightness(light, 400000, 10)
self.assertEqual((light / "brightness").read_text(), "40000")
set_brightness(light, 400000, 100)
self.assertEqual((light / "brightness").read_text(), "400000")
(light / "max_brightness").write_text("0")
self.assertIsNone(backlight(root / "backlight"))
(light / "max_brightness").unlink()
self.assertIsNone(backlight(root / "backlight"))
self.assertIsNone(backlight(root / "missing"))
with self.assertRaises(OSError):
set_brightness(root / "missing", 400000, 50)
def test_reject_oversized_response(self):
temp = tempfile.TemporaryDirectory()
path = temp.name + "/socket"
server = socket.socket(socket.AF_UNIX)
server.bind(path)
server.listen(1)
def serve():
conn, _ = server.accept()
with conn:
conn.recv(4096)
conn.sendall(struct.pack("=I", MAX_FRAME + 1))
thread = threading.Thread(target=serve, daemon=True)
thread.start()
try:
with Client(path) as client:
with self.assertRaises(ProtocolError):
client.create("example")
finally:
server.close()
thread.join(2)
temp.cleanup()
def test_avatar_conversion_and_system_boundary(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
original = root / "portrait.jpg"
Image.new("RGB", (400, 200), (10, 20, 30)).save(original)
self.assertEqual(source_path(original.as_uri()), original)
converted = root / "avatar.png"
render_png(original, converted)
with Image.open(converted) as image:
self.assertEqual(image.format, "PNG")
self.assertEqual(image.size, (256, 256))
with self.assertRaises(AvatarError):
source_path("https://example.invalid/avatar.png")
too_large = root / "too-large.jpg"
too_large.write_bytes(b"x" * (12 * 1024 * 1024 + 1))
with self.assertRaises(AvatarError):
source_path(str(too_large))
self.assertIsNone(system_icon("../etc/passwd", root))
self.assertIsNone(system_icon("example", root))
(root / "example").symlink_to(converted)
self.assertIsNone(system_icon("example", root))
class DeploymentTests(unittest.TestCase):
def setUp(self):
spec = importlib.util.spec_from_file_location("session_update", Path(__file__).resolve().parents[1] / "scripts/session-update.py")
self.module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.module)
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.bundle = self.root / "bundle"
self.bundle.mkdir(mode=0o700)
self.module.OWNER = os.getuid()
self.module.TARGETS = {name: self.root / name for name in self.module.TARGETS}
self.module.ORIGINALS = {}
manifest = {"version": 1, "files": {}}
for name, target in self.module.TARGETS.items():
old, new = ("old:" + name).encode(), ("new:" + name).encode()
target.write_bytes(old)
target.chmod(0o644)
(self.bundle / (name + ".old")).write_bytes(old)
(self.bundle / (name + ".new")).write_bytes(new)
manifest["files"][name] = {"old": self.module.digest(old), "new": self.module.digest(new)}
self.module.ORIGINALS[name] = self.module.digest(old)
(self.bundle / "manifest.json").write_text(json.dumps(manifest))
(self.bundle / "session-update.py").write_bytes(Path(self.module.__file__).read_bytes())
for item in self.bundle.iterdir():
item.chmod(0o600)
# Seule la confiance des parents est adaptée au LAB non-root sous /var/tmp.
# Lecture, SHA, permissions, journal, fsync et rename sont ceux du vrai outil.
self.guard = patch.object(self.module, "trusted_parents")
self.guard.start()
self.addCleanup(self.guard.stop)
def assert_state(self, prefix):
for name, path in self.module.TARGETS.items():
self.assertEqual(path.read_bytes(), (prefix + ":" + name).encode())
self.assertEqual(path.stat().st_mode & 0o7777, 0o644)
def test_durable_barrier_failure_never_publishes_or_rolls_back(self):
original = os.fsync
for failed in (self.bundle / 'session-update.py', self.bundle.parent):
with self.subTest(failed=failed):
def sync(fd):
if Path(os.readlink('/proc/self/fd/' + str(fd))) == failed:
raise OSError('barrier failure')
return original(fd)
with patch.object(os, 'fsync', side_effect=sync), patch.object(self.module, 'publish') as publish:
with self.assertRaises(OSError):
self.module.transact(self.bundle, 'apply')
publish.assert_not_called()
self.assert_state('old')
self.assertFalse((self.bundle / 'state').exists())
def test_apply_and_rollback_idempotent(self):
for operation, state in (("apply", "new"), ("apply", "new"),
("rollback", "old"), ("rollback", "old")):
self.module.transact(self.bundle, operation)
self.assert_state(state)
def test_ordinary_write_failure_rolls_back_complete_batch(self):
original = self.module.atomic_write
writes = []
def failing(path, *args):
if path in self.module.TARGETS.values():
writes.append(path)
if len(writes) == 2:
raise OSError("échec synthétique")
return original(path, *args)
with patch.object(self.module, "atomic_write", side_effect=failing):
with self.assertRaises(OSError):
self.module.transact(self.bundle, "apply")
self.assert_state("old")
self.assertEqual((self.bundle / "state").read_text(), "original\n")
def test_killed_process_recovers_before_new_apply(self):
# Le fils meurt après le deuxième vrai rename, sans exécuter except/finally.
pid = os.fork()
if pid == 0:
original = self.module.atomic_write
count = [0]
def die(path, *args):
original(path, *args)
if path in self.module.TARGETS.values():
count[0] += 1
if count[0] == 2:
os._exit(77)
self.module.atomic_write = die
try:
self.module.transact(self.bundle, "apply")
finally:
os._exit(78)
_, status = os.waitpid(pid, 0)
self.assertEqual(os.waitstatus_to_exitcode(status), 77)
self.assertEqual((self.bundle / "state").read_text(), "applying\n")
with self.assertRaisesRegex(ValueError, "interrompue restaurée"):
self.module.transact(self.bundle, "apply")
self.assert_state("old")
self.module.transact(self.bundle, "apply")
self.assert_state("new")
self.module.transact(self.bundle, "rollback")
self.assert_state("old")
def test_drift_bad_hash_mode_symlink_rejected_before_write(self):
target = next(iter(self.module.TARGETS.values()))
original = target.read_bytes()
target.write_bytes(b"outside change")
with self.assertRaises(ValueError):
self.module.transact(self.bundle, "apply")
self.assertFalse((self.bundle / "state").exists())
self.assertEqual(target.read_bytes(), b"outside change")
target.write_bytes(original)
target.chmod(0o666)
with self.assertRaises(ValueError):
self.module.transact(self.bundle, "apply")
target.chmod(0o644)
backup = target.with_suffix(".link-target")
target.rename(backup)
target.symlink_to(backup)
with self.assertRaises(OSError):
self.module.transact(self.bundle, "apply")
target.unlink()
backup.rename(target)
(self.bundle / (target.name + ".new")).write_bytes(b"tampered")
with self.assertRaises(ValueError):
self.module.transact(self.bundle, "apply")
self.assert_state("old")
@unittest.skipUnless(os.environ.get("LABFY_GTK_TESTS") == "1", "rendu GTK Wayland explicitement activé seulement")
class GtkTests(unittest.TestCase):
def pump(self, condition):
from gi.repository import GLib
deadline = time.monotonic() + 5
while not condition() and time.monotonic() < deadline:
while GLib.MainContext.default().pending():
GLib.MainContext.default().iteration(False)
time.sleep(0.005)
self.assertTrue(condition(), "retour GTK absent")
def test_gtk_css_parses_without_error(self):
from labfy_greeter import Gtk
provider = Gtk.CssProvider()
errors = []
provider.connect("parsing-error", lambda _provider, _section, error: errors.append(str(error)))
provider.load_from_path(str(Path(__file__).resolve().parents[1] / "style/labfy-greeter.css"))
self.assertEqual(errors, [])
def test_real_widgets_preview_and_pam_snapshot_busy_retry(self):
import labfy_greeter as ui
from gi.repository import Gtk
app = ui.Greeter(preview=True)
app.register(None)
with patch.object(ui, "AuthFlow", side_effect=AssertionError("PAM en aperçu")):
app.activate()
try:
self.assertEqual(app.session_choice(), "stable")
app.session_selector.set_selected(1)
self.assertEqual(app.session_choice(), "mosaic")
self.assertIsNone(app.flow)
app.submit()
app.perform(lambda: self.fail("worker en aperçu"))
self.assertFalse(app.busy)
self.assertFalse(app.button.get_sensitive())
self.assertFalse(app.entry.get_sensitive())
self.assertFalse(app.slider.get_sensitive())
entered, release = threading.Event(), threading.Event()
def delayed_failure():
entered.set()
release.wait(3)
return {"type": "error", "error_type": "auth_error"}
first = FakeGreetd([msg("secret"), delayed_failure, {"type": "success"}])
second = FakeGreetd([msg("secret"), {"type": "success"},
{"type": "error", "error_type": "error"}, {"type": "success"}])
try:
# Même fenêtre que l'aperçu, sans fullscreen ni socket/session réels.
app.preview = False
app.flow = AuthFlow(first.path)
app.selector.set_model(Gtk.StringList.new(["example", "other", "Autre utilisateur…"]))
app.selection_ready = True
app.user_changed()
self.pump(lambda: not app.busy)
self.assertEqual(app.flow.selection, "mosaic")
app.entry.set_text(SECRET)
app.submit()
self.assertTrue(entered.wait(2))
for widget in (app.selector, app.session_selector, app.manual, app.button, app.entry):
self.assertFalse(widget.get_sensitive())
app.submit() # double clic : aucun deuxième worker/réponse
release.set()
self.pump(lambda: not app.busy)
self.assertIsNone(app.flow.client)
self.assertTrue(app.session_selector.get_sensitive())
self.assertEqual(len([r for r in first.requests if r["type"] == "post_auth_message_response"]), 1)
app.flow.socket_path = second.path
app.session_selector.set_selected(0)
self.pump(lambda: not app.busy)
self.assertEqual(app.flow.selection, "stable")
app.entry.set_text(SECRET)
app.submit()
self.pump(lambda: not app.busy and "Lancement" in app.error.get_text())
self.assertIsNone(app.flow.client)
self.assertEqual(second.requests[-2], {"type": "start_session", "cmd": STABLE, "env": []})
self.assertTrue(app.selector.get_sensitive())
finally:
release.set()
first.close()
second.close()
finally:
app.preview = True
app.window.destroy()
app.quit()
if __name__ == "__main__":
unittest.main()