mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
The Disks page treated /media/wrolpi as fully protected, so a manually-mounted primary drive could not be persisted from the UI — and the Persist toggle would have been wrong anyway: it wrote fstab.yaml, which the wrolpi-mounts reconciler refuses to apply for the primary mount (RESERVED_MOUNT_POINTS). Route Persist for the primary mount to the host's /etc/fstab (as onboarding does), report its entry in the fstab list endpoint, and enable the toggle in both UIs while keeping Unmount blocked. Mounts outside /media (e.g. /boot/efi, which showed an Unmount button) are now entirely untouchable System mounts. Also: - Run `ntfsfix -d` before mounting ntfs/ntfs3 volumes so the Windows dirty flag (Fast Startup, unclean unplug) cannot block mounting; non-fatal and a no-op on clean volumes. - Add uid/gid fstab options for ntfs/ntfs3, matching mount_drive(). - Log reconcile diagnostics (desired/already-mounted/skipped with reasons) so an idle wrolpi-mounts run explains itself in the journal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
391 lines
18 KiB
Python
391 lines
18 KiB
Python
"""
|
|
Unit tests for controller.lib.fstab module.
|
|
"""
|
|
|
|
from unittest import mock
|
|
|
|
from controller.lib.fstab import (
|
|
parse_fstab,
|
|
add_fstab_entry,
|
|
remove_fstab_entry,
|
|
get_wrolpi_fstab_entries,
|
|
)
|
|
|
|
|
|
class TestParseFstab:
|
|
"""Tests for parse_fstab function."""
|
|
|
|
def test_parses_mount_entries(self):
|
|
"""Should parse mount entries from fstab."""
|
|
fstab_content = """\
|
|
# /etc/fstab
|
|
UUID=1234-5678 / ext4 defaults 0 1
|
|
UUID=abcd-efgh /media/wrolpi ext4 defaults,nofail 0 2
|
|
"""
|
|
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
|
|
result = parse_fstab()
|
|
mounts = [e for e in result if e["type"] == "mount"]
|
|
assert len(mounts) == 2
|
|
assert mounts[0]["device"] == "UUID=1234-5678"
|
|
assert mounts[0]["mount_point"] == "/"
|
|
assert mounts[1]["mount_point"] == "/media/wrolpi"
|
|
|
|
def test_preserves_comments(self):
|
|
"""Should preserve comments and blank lines."""
|
|
fstab_content = """\
|
|
# This is a comment
|
|
|
|
UUID=1234 / ext4 defaults 0 1
|
|
"""
|
|
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
|
|
result = parse_fstab()
|
|
comments = [e for e in result if e["type"] == "comment"]
|
|
assert len(comments) == 2
|
|
|
|
def test_handles_minimal_entries(self):
|
|
"""Should handle entries with minimal fields."""
|
|
fstab_content = "UUID=1234 /media/data ext4 defaults\n"
|
|
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
|
|
result = parse_fstab()
|
|
mount = [e for e in result if e["type"] == "mount"][0]
|
|
assert mount["dump"] == "0"
|
|
assert mount["pass"] == "0"
|
|
|
|
|
|
class TestAddFstabEntry:
|
|
"""Tests for add_fstab_entry function."""
|
|
|
|
def test_blocked_in_wrol_mode(self):
|
|
"""Should be blocked in WROL mode."""
|
|
with mock.patch("controller.lib.fstab.require_normal_mode", side_effect=PermissionError("WROL")):
|
|
result = add_fstab_entry("/dev/sda1", "/media/test", "ext4")
|
|
assert result["success"] is False
|
|
assert "WROL" in result["error"]
|
|
|
|
def test_validates_mount_point(self):
|
|
"""Should validate mount point."""
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
result = add_fstab_entry("/dev/sda1", "/mnt/test", "ext4")
|
|
assert result["success"] is False
|
|
assert "must be under /media" in result["error"]
|
|
|
|
def test_converts_device_to_uuid(self):
|
|
"""Should convert device path to UUID."""
|
|
fstab_content = "# empty\n"
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.get_uuid", return_value="1234-5678"):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
|
|
with mock.patch("subprocess.run"):
|
|
result = add_fstab_entry("/dev/sda1", "/media/test", "ext4")
|
|
assert result["success"] is True
|
|
assert result["device"] == "UUID=1234-5678"
|
|
|
|
def test_adds_entry_to_fstab(self):
|
|
"""Should add entry to fstab."""
|
|
fstab_content = "# existing\n"
|
|
written_content = []
|
|
|
|
def mock_write(content):
|
|
written_content.append(content)
|
|
|
|
mock_file = mock.mock_open(read_data=fstab_content)
|
|
mock_file().write = mock_write
|
|
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.get_uuid", return_value=None):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("builtins.open", mock_file):
|
|
with mock.patch("subprocess.run"):
|
|
result = add_fstab_entry("/dev/sda1", "/media/test", "ext4")
|
|
assert result["success"] is True
|
|
# Check that something was written
|
|
assert len(written_content) > 0
|
|
|
|
def test_replaces_entry_without_duplicating_comments(self):
|
|
"""Should remove old WROLPi comment when replacing an entry."""
|
|
fstab_content = """\
|
|
# /etc/fstab
|
|
UUID=1234 / ext4 defaults 0 1
|
|
# WROLPi managed mount - added 2025-12-15T15:57:37.675757
|
|
UUID=5678 /media/test ext4 defaults,nofail 0 2
|
|
"""
|
|
written_content = []
|
|
|
|
def mock_write(content):
|
|
written_content.append(content)
|
|
|
|
mock_file = mock.mock_open(read_data=fstab_content)
|
|
mock_file().write = mock_write
|
|
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.get_uuid", return_value="5678"):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("builtins.open", mock_file):
|
|
with mock.patch("subprocess.run"):
|
|
result = add_fstab_entry("/dev/sda1", "/media/test", "ext4")
|
|
assert result["success"] is True
|
|
written_str = "".join(written_content)
|
|
# Should have exactly ONE WROLPi comment (the new one)
|
|
assert written_str.count("WROLPi managed mount") == 1
|
|
# The old timestamp should NOT be there
|
|
assert "2025-12-15T15:57:37.675757" not in written_str
|
|
|
|
def test_replaces_entry_for_same_uuid_different_mount_point(self):
|
|
"""Should remove old entry when adding same UUID to different mount point.
|
|
|
|
Scenario: User mounts /dev/sda1 (UUID=5678) to /media/64GB with persist,
|
|
then unmounts, then mounts same device to /media/64GB-2 with persist.
|
|
The old /media/64GB entry should be removed.
|
|
"""
|
|
fstab_content = """\
|
|
# /etc/fstab
|
|
UUID=1234 / ext4 defaults 0 1
|
|
# WROLPi managed mount - added 2025-12-15T15:57:37.675757
|
|
UUID=5678 /media/64GB exfat defaults,nofail,x-systemd.device-timeout=10s 0 2
|
|
"""
|
|
written_content = []
|
|
|
|
def mock_write(content):
|
|
written_content.append(content)
|
|
|
|
mock_file = mock.mock_open(read_data=fstab_content)
|
|
mock_file().write = mock_write
|
|
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.get_uuid", return_value="5678"):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("controller.lib.fstab.get_wrolpi_uid_gid", return_value=(1001, 1001)):
|
|
with mock.patch("builtins.open", mock_file):
|
|
with mock.patch("subprocess.run"):
|
|
# Mount same device to different mount point
|
|
result = add_fstab_entry("/dev/sda1", "/media/64GB-2", "exfat")
|
|
assert result["success"] is True
|
|
written_str = "".join(written_content)
|
|
# Should only have ONE entry for UUID=5678 (the new one)
|
|
assert written_str.count("UUID=5678") == 1
|
|
# Should have the new mount point
|
|
assert "/media/64GB-2" in written_str
|
|
# Should NOT have the old mount point
|
|
assert "/media/64GB " not in written_str
|
|
# Should have exactly ONE WROLPi comment
|
|
assert written_str.count("WROLPi managed mount") == 1
|
|
|
|
def test_exfat_fstab_entry_includes_uid_gid(self):
|
|
"""Should add uid/gid options when adding exfat fstab entry."""
|
|
fstab_content = "# empty\n"
|
|
written_content = []
|
|
|
|
def mock_write(content):
|
|
written_content.append(content)
|
|
|
|
mock_file = mock.mock_open(read_data=fstab_content)
|
|
mock_file().write = mock_write
|
|
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.get_uuid", return_value="1234-5678"):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("controller.lib.fstab.get_wrolpi_uid_gid", return_value=(1001, 1001)):
|
|
with mock.patch("builtins.open", mock_file):
|
|
with mock.patch("subprocess.run"):
|
|
result = add_fstab_entry("/dev/sda1", "/media/usb", "exfat")
|
|
assert result["success"] is True
|
|
written_str = "".join(written_content)
|
|
# Should have uid/gid options
|
|
assert "uid=1001" in written_str
|
|
assert "gid=1001" in written_str
|
|
|
|
def test_ext4_fstab_entry_does_not_include_uid_gid(self):
|
|
"""Should not add uid/gid options when adding ext4 fstab entry."""
|
|
fstab_content = "# empty\n"
|
|
written_content = []
|
|
|
|
def mock_write(content):
|
|
written_content.append(content)
|
|
|
|
mock_file = mock.mock_open(read_data=fstab_content)
|
|
mock_file().write = mock_write
|
|
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.get_uuid", return_value="1234-5678"):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("builtins.open", mock_file):
|
|
with mock.patch("subprocess.run"):
|
|
result = add_fstab_entry("/dev/sda1", "/media/data", "ext4")
|
|
assert result["success"] is True
|
|
written_str = "".join(written_content)
|
|
# Should NOT have uid/gid options
|
|
assert "uid=" not in written_str
|
|
assert "gid=" not in written_str
|
|
|
|
def test_ntfs_fstab_entry_includes_uid_gid(self):
|
|
"""Should add uid/gid options when adding ntfs fstab entry (no POSIX permissions)."""
|
|
fstab_content = "# empty\n"
|
|
written_content = []
|
|
|
|
def mock_write(content):
|
|
written_content.append(content)
|
|
|
|
mock_file = mock.mock_open(read_data=fstab_content)
|
|
mock_file().write = mock_write
|
|
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.get_uuid", return_value="1234-5678"):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("controller.lib.fstab.get_wrolpi_uid_gid", return_value=(1001, 1001)):
|
|
with mock.patch("builtins.open", mock_file):
|
|
with mock.patch("subprocess.run"):
|
|
result = add_fstab_entry("/dev/sdb2", "/media/wrolpi", "ntfs")
|
|
assert result["success"] is True
|
|
written_str = "".join(written_content)
|
|
# Should have uid/gid options
|
|
assert "uid=1001" in written_str
|
|
assert "gid=1001" in written_str
|
|
|
|
def test_vfat_fstab_entry_includes_uid_gid(self):
|
|
"""Should add uid/gid options when adding vfat (FAT32) fstab entry."""
|
|
fstab_content = "# empty\n"
|
|
written_content = []
|
|
|
|
def mock_write(content):
|
|
written_content.append(content)
|
|
|
|
mock_file = mock.mock_open(read_data=fstab_content)
|
|
mock_file().write = mock_write
|
|
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.get_uuid", return_value="1234-5678"):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("controller.lib.fstab.get_wrolpi_uid_gid", return_value=(1001, 1001)):
|
|
with mock.patch("builtins.open", mock_file):
|
|
with mock.patch("subprocess.run"):
|
|
result = add_fstab_entry("/dev/sda1", "/media/usb", "vfat")
|
|
assert result["success"] is True
|
|
written_str = "".join(written_content)
|
|
# Should have uid/gid options
|
|
assert "uid=1001" in written_str
|
|
assert "gid=1001" in written_str
|
|
|
|
|
|
class TestRemoveFstabEntry:
|
|
"""Tests for remove_fstab_entry function."""
|
|
|
|
def test_blocked_in_wrol_mode(self):
|
|
"""Should be blocked in WROL mode."""
|
|
with mock.patch("controller.lib.fstab.require_normal_mode", side_effect=PermissionError("WROL")):
|
|
result = remove_fstab_entry("/media/test")
|
|
assert result["success"] is False
|
|
|
|
def test_validates_mount_point(self):
|
|
"""Should validate mount point."""
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
result = remove_fstab_entry("/mnt/test")
|
|
assert result["success"] is False
|
|
|
|
def test_returns_error_if_not_found(self):
|
|
"""Should return error if entry not found."""
|
|
fstab_content = "UUID=1234 / ext4 defaults 0 1\n"
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
|
|
result = remove_fstab_entry("/media/nonexistent")
|
|
assert result["success"] is False
|
|
assert "No entry found" in result["error"]
|
|
|
|
def test_removes_wrolpi_comment_with_entry(self):
|
|
"""Should remove WROLPi comment when removing its associated entry."""
|
|
fstab_content = """\
|
|
# /etc/fstab
|
|
UUID=1234 / ext4 defaults 0 1
|
|
# WROLPi managed mount - added 2025-12-15T15:57:37.675757
|
|
UUID=5678 /media/test ext4 defaults,nofail 0 2
|
|
"""
|
|
written_content = []
|
|
|
|
def mock_write(content):
|
|
written_content.append(content)
|
|
|
|
mock_file = mock.mock_open(read_data=fstab_content)
|
|
mock_file().write = mock_write
|
|
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("builtins.open", mock_file):
|
|
with mock.patch("subprocess.run"):
|
|
result = remove_fstab_entry("/media/test")
|
|
assert result["success"] is True
|
|
# WROLPi comment should NOT be in written content
|
|
written_str = "".join(written_content)
|
|
assert "WROLPi managed mount" not in written_str
|
|
# Regular comment should still be there
|
|
assert "# /etc/fstab" in written_str
|
|
|
|
def test_preserves_non_wrolpi_comments(self):
|
|
"""Should preserve comments that are not WROLPi managed mount comments."""
|
|
fstab_content = """\
|
|
# /etc/fstab
|
|
UUID=1234 / ext4 defaults 0 1
|
|
# User added this mount manually
|
|
UUID=5678 /media/test ext4 defaults,nofail 0 2
|
|
"""
|
|
written_content = []
|
|
|
|
def mock_write(content):
|
|
written_content.append(content)
|
|
|
|
mock_file = mock.mock_open(read_data=fstab_content)
|
|
mock_file().write = mock_write
|
|
|
|
with mock.patch("controller.lib.fstab.require_normal_mode"):
|
|
with mock.patch("controller.lib.fstab.backup_fstab"):
|
|
with mock.patch("builtins.open", mock_file):
|
|
with mock.patch("subprocess.run"):
|
|
result = remove_fstab_entry("/media/test")
|
|
assert result["success"] is True
|
|
# User's manual comment should still be there
|
|
written_str = "".join(written_content)
|
|
assert "# User added this mount manually" in written_str
|
|
|
|
|
|
class TestGetWrolpiFstabEntries:
|
|
"""Tests for get_wrolpi_fstab_entries function."""
|
|
|
|
def test_returns_all_media_entries(self):
|
|
"""Should return all entries under /media, not just /media/wrolpi."""
|
|
fstab_content = """\
|
|
UUID=1234 / ext4 defaults 0 1
|
|
UUID=5678 /media/wrolpi ext4 defaults 0 2
|
|
UUID=9abc /media/wrolpi/data ext4 defaults 0 2
|
|
UUID=defg /media/usb vfat defaults 0 0
|
|
UUID=hijk /media/roland/64GB exfat defaults 0 0
|
|
"""
|
|
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
|
|
result = get_wrolpi_fstab_entries()
|
|
# Should include all /media/* entries
|
|
assert len(result) == 4
|
|
mount_points = [e["mount_point"] for e in result]
|
|
assert "/media/wrolpi" in mount_points
|
|
assert "/media/wrolpi/data" in mount_points
|
|
assert "/media/usb" in mount_points
|
|
assert "/media/roland/64GB" in mount_points
|
|
# Root should NOT be included
|
|
assert "/" not in mount_points
|
|
|
|
def test_excludes_non_media_mounts(self):
|
|
"""Should exclude mounts outside /media."""
|
|
fstab_content = """\
|
|
UUID=1234 / ext4 defaults 0 1
|
|
UUID=5678 /home ext4 defaults 0 2
|
|
UUID=9abc /media/wrolpi ext4 defaults 0 2
|
|
"""
|
|
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
|
|
result = get_wrolpi_fstab_entries()
|
|
assert len(result) == 1
|
|
assert result[0]["mount_point"] == "/media/wrolpi"
|
|
|
|
def test_returns_empty_if_no_media_entries(self):
|
|
"""Should return empty list if no entries under /media."""
|
|
fstab_content = "UUID=1234 / ext4 defaults 0 1\n"
|
|
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
|
|
result = get_wrolpi_fstab_entries()
|
|
assert result == []
|