Samba is installed and can be configured in the Controller.

This commit is contained in:
Roland 2026-03-28 16:28:47 -06:00 • committed by lrnselfreliance
parent 0ceac364ec
commit ce0cdd3a2e
15 changed files with 837 additions and 5 deletions

View file

@ -107,6 +107,23 @@ export async function disableBluetooth() {
return controllerFetch('/bluetooth/disable', {method: 'POST'});
}
// --- Samba Endpoints ---
export async function getSambaStatus() {
return controllerFetch('/samba/status');
}
export async function addSambaShare(name, path, readOnly = true, comment = '') {
return controllerFetch('/samba/shares', {
method: 'POST',
body: JSON.stringify({name, path, read_only: readOnly, comment}),
});
}
export async function removeSambaShare(shareName) {
return controllerFetch(`/samba/shares/${encodeURIComponent(shareName)}`, {method: 'DELETE'});
}
export async function shutdownSystem() {
return controllerFetch('/shutdown', {method: 'POST'});
}

View file

@ -1,20 +1,23 @@
import React from 'react';
import {Checkbox, Container, Dropdown, Form, Icon, Input} from "semantic-ui-react";
import {Button, Confirm, Header, Loader, Modal, Segment, Table} from "../Theme";
import {APIButton, BluetoothToggle, HandPointMessage, HotspotToggle, InfoMessage, ThrottleToggle, Toggle,} from "../Common";
import {useDockerized} from "../../hooks/customHooks";
import {APIButton, BluetoothToggle, DirectorySearch, HandPointMessage, HotspotToggle, InfoMessage, ThrottleToggle, Toggle,} from "../Common";
import {useDockerized, useMediaDirectory} from "../../hooks/customHooks";
import {Media} from "../../contexts/contexts";
import {
addFstabEntry,
addSambaShare,
disableService,
enableService,
getDisks,
getFstabEntries,
getSambaStatus,
getServiceLogs,
getServices,
getSmartStatus,
mountDisk,
removeFstabEntry,
removeSambaShare,
restartService,
restartServices,
startService,
@ -1053,6 +1056,215 @@ function DiskSection() {
}
function SambaSection() {
const [status, setStatus] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [actionLoading, setActionLoading] = React.useState(false);
const [addOpen, setAddOpen] = React.useState(false);
const [shareAll, setShareAll] = React.useState(true);
const [newName, setNewName] = React.useState('');
const [newPath, setNewPath] = React.useState('');
const [newReadOnly, setNewReadOnly] = React.useState(true);
const [newComment, setNewComment] = React.useState('');
const [removeConfirmName, setRemoveConfirmName] = React.useState(null);
const dockerized = useDockerized();
const mediaDirectory = useMediaDirectory();
const fetchStatus = async () => {
try {
const result = await getSambaStatus();
setStatus(result);
} catch (e) {
console.error('Failed to fetch Samba status:', e);
} finally {
setLoading(false);
}
};
React.useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 10000);
return () => clearInterval(interval);
}, []);
const handleAddShare = async () => {
if (!newName.trim()) return;
setActionLoading(true);
const absolutePath = shareAll
? mediaDirectory
: `${mediaDirectory}/${newPath.trim()}`;
try {
await addSambaShare(newName.trim(), absolutePath, newReadOnly, newComment.trim());
toast({type: 'success', title: 'Share Added', description: `Added share "${newName}"`, time: 3000});
setAddOpen(false);
setShareAll(true);
setNewName('');
setNewPath('');
setNewReadOnly(true);
setNewComment('');
await fetchStatus();
} catch (e) {
toast({type: 'error', title: 'Add Share Failed', description: e.message, time: 5000});
} finally {
setActionLoading(false);
}
};
const handleRemoveShare = async (name) => {
setActionLoading(true);
try {
await removeSambaShare(name);
toast({type: 'success', title: 'Share Removed', description: `Removed share "${name}"`, time: 3000});
await fetchStatus();
} catch (e) {
toast({type: 'error', title: 'Remove Share Failed', description: e.message, time: 5000});
} finally {
setActionLoading(false);
setRemoveConfirmName(null);
}
};
if (loading) {
return <Segment><Loader active inline='centered'/></Segment>;
}
if (!status || !status.available) {
if (dockerized) return null;
return (
<Segment>
<Header as='h3'>
<Icon name='folder open'/>
Network Shares (Samba)
</Header>
<Message info>Samba is not available on this system.</Message>
</Segment>
);
}
const shares = status.shares || [];
return (
<Segment>
<Header as='h3'>
<Icon name='folder open'/>
Network Shares (Samba)
</Header>
<p>Configure directories to share over the local network. Start the smbd service to activate sharing.</p>
{shares.length > 0 && (
<Table celled style={{marginTop: '1em'}}>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Path</Table.HeaderCell>
<Table.HeaderCell>Read Only</Table.HeaderCell>
<Table.HeaderCell>Comment</Table.HeaderCell>
<Table.HeaderCell>Actions</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{shares.map(share => (
<Table.Row key={share.name}>
<Table.Cell>{share.name}</Table.Cell>
<Table.Cell><code>{share.path}</code></Table.Cell>
<Table.Cell>{share.read_only ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>{share.comment}</Table.Cell>
<Table.Cell>
<Button
size='tiny'
color='red'
icon='trash'
disabled={actionLoading}
onClick={() => setRemoveConfirmName(share.name)}
/>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
)}
<Confirm
open={removeConfirmName !== null}
header='Remove Share'
content={`Remove the share "${removeConfirmName}"?`}
confirmButton='Remove'
onCancel={() => setRemoveConfirmName(null)}
onConfirm={() => handleRemoveShare(removeConfirmName)}
/>
<div style={{marginTop: '1em'}}>
<Button
icon='plus'
content='Add Share'
onClick={() => setAddOpen(true)}
disabled={actionLoading}
/>
</div>
<Modal open={addOpen} onClose={() => { setAddOpen(false); setShareAll(true); }} size='small'>
<Modal.Header>Add Samba Share</Modal.Header>
<Modal.Content>
<Form>
<Form.Field>
<label>Share Name</label>
<Input
placeholder='e.g. Documents'
value={newName}
onChange={(e, {value}) => setNewName(value)}
/>
</Form.Field>
<Form.Field>
<Checkbox
label='Share all files'
checked={shareAll}
onChange={(e, {checked}) => setShareAll(checked)}
/>
</Form.Field>
{!shareAll && (
<Form.Field>
<label>Path</label>
<DirectorySearch
onSelect={setNewPath}
value={newPath}
/>
</Form.Field>
)}
<Form.Field>
<Checkbox
label='Read Only'
checked={newReadOnly}
onChange={(e, {checked}) => setNewReadOnly(checked)}
/>
</Form.Field>
<Form.Field>
<label>Comment (optional)</label>
<Input
placeholder='Description of this share'
value={newComment}
onChange={(e, {value}) => setNewComment(value)}
/>
</Form.Field>
</Form>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
<Button
color='green'
icon='plus'
content='Add Share'
disabled={!newName.trim() || actionLoading}
loading={actionLoading}
onClick={handleAddShare}
/>
</Modal.Actions>
</Modal>
</Segment>
);
}
function AdminControlsSection() {
const dockerized = useDockerized();
@ -1089,6 +1301,7 @@ export function ControllerPage() {
<Container fluid>
<AdminControlsSection/>
<ServicesSection/>
<SambaSection/>
<DiskSection/>
<ControllerLink/>
</Container>

View file

@ -34,12 +34,16 @@ jest.mock('../../api/controller', () => ({
getMounts: jest.fn().mockResolvedValue([]),
getSmartStatus: jest.fn().mockResolvedValue({}),
restartServices: jest.fn().mockResolvedValue({success: true}),
getSambaStatus: jest.fn().mockResolvedValue({enabled: false, available: true, shares: []}),
addSambaShare: jest.fn().mockResolvedValue({success: true}),
removeSambaShare: jest.fn().mockResolvedValue({success: true}),
}));
// Mock useDockerized hook
// Mock hooks
jest.mock('../../hooks/customHooks', () => ({
useDockerized: jest.fn().mockReturnValue(false),
useMediaDirectory: jest.fn().mockReturnValue('/media/wrolpi'),
useBluetooth: jest.fn().mockReturnValue({on: false, setBluetooth: jest.fn()}),
useHotspot: jest.fn().mockReturnValue({on: true, setHotspot: jest.fn()}),
useThrottle: jest.fn().mockReturnValue({on: false, setThrottle: jest.fn()}),
@ -66,6 +70,9 @@ jest.mock('../Common', () => {
APIButton: ({children, onClick, disabled}) => (
<button onClick={onClick} disabled={disabled}>{children}</button>
),
DirectorySearch: ({onSelect, value}) => (
<input data-testid="directory-search" value={value || ''} onChange={e => onSelect && onSelect(e.target.value)}/>
),
};
});

View file

@ -10,6 +10,10 @@ from controller.api.schemas import (
HotspotActionResponse,
HotspotStatusResponse,
RestartServicesResponse,
SambaShareAddRequest,
SambaShareRemoveResponse,
SambaShareResponse,
SambaStatusResponse,
ServiceRestartResult,
SystemActionResponse,
ThrottleActionResponse,
@ -35,6 +39,11 @@ from controller.lib.admin import (
shutdown_system,
)
from controller.lib.config import is_docker_mode
from controller.lib.samba import (
add_share,
get_samba_status_dict,
remove_share,
)
router = APIRouter(tags=["admin"])
@ -91,6 +100,32 @@ async def bluetooth_disable() -> BluetoothActionResponse:
return BluetoothActionResponse(**result)
# --- Samba ---
@router.get("/api/samba/status", response_model=SambaStatusResponse)
async def samba_status() -> SambaStatusResponse:
"""Get Samba sharing status."""
return SambaStatusResponse(**get_samba_status_dict())
@router.post("/api/samba/shares", response_model=SambaShareResponse)
async def samba_add_share(request: SambaShareAddRequest) -> SambaShareResponse:
"""Add a Samba share."""
result = add_share(request.name, request.path, request.read_only, request.comment or "")
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
return SambaShareResponse(**result)
@router.delete("/api/samba/shares/{share_name}", response_model=SambaShareRemoveResponse)
async def samba_remove_share(share_name: str) -> SambaShareRemoveResponse:
"""Remove a Samba share."""
result = remove_share(share_name)
if not result.get("success"):
raise HTTPException(status_code=404, detail=result.get("error", "Failed"))
return SambaShareRemoveResponse(**result)
# --- Throttle ---
@router.get("/api/throttle/status", response_model=ThrottleStatusResponse)

View file

@ -185,6 +185,52 @@ class BluetoothActionResponse(BaseModel):
error: Optional[str] = Field(default=None, description="Error message if failed")
# ============================================================================
# Admin - Samba
# ============================================================================
class SambaShareInfo(BaseModel):
"""Information about a single Samba share."""
name: str = Field(description="Share name")
path: str = Field(description="Filesystem path being shared")
read_only: bool = Field(description="Whether share is read-only")
comment: Optional[str] = Field(default="", description="Share description")
class SambaStatusResponse(BaseModel):
"""Response model for /api/samba/status endpoint."""
enabled: bool = Field(description="Whether Samba is currently running")
available: bool = Field(description="Whether Samba functionality is available")
reason: Optional[str] = Field(default=None, description="Reason if unavailable")
shares: list[SambaShareInfo] = Field(default_factory=list, description="Configured shares")
class SambaShareAddRequest(BaseModel):
"""Request model for adding a Samba share."""
name: str = Field(description="Share name (alphanumeric, hyphens, underscores)")
path: str = Field(description="Filesystem path to share")
read_only: bool = Field(default=True, description="Whether share is read-only")
comment: Optional[str] = Field(default="", description="Share description")
class SambaShareResponse(BaseModel):
"""Response model for share add/update actions."""
success: bool = Field(description="Whether the action succeeded")
share: Optional[SambaShareInfo] = Field(default=None, description="The share")
error: Optional[str] = Field(default=None, description="Error message if failed")
class SambaShareRemoveResponse(BaseModel):
"""Response model for removing a Samba share."""
success: bool = Field(description="Whether the share was removed")
error: Optional[str] = Field(default=None, description="Error message if failed")
# ============================================================================
# Admin - Throttle
# ============================================================================

View file

@ -109,6 +109,23 @@ DEFAULT_CONFIG = {
"use_https": False,
"description": "Web proxy",
},
# Samba file sharing services
{
"name": "smbd",
"systemd_name": "smbd",
"port": 445,
"viewable": False,
"use_https": False,
"description": "Samba file sharing",
},
{
"name": "nmbd",
"systemd_name": "nmbd",
"port": 137,
"viewable": False,
"use_https": False,
"description": "Samba NetBIOS name service",
},
# Upgrade service - only shown when running
{
"name": "wrolpi-upgrade",
@ -130,4 +147,9 @@ DEFAULT_CONFIG = {
"throttle": {
"default_governor": "ondemand",
},
"samba": {
"shares": [],
# Each share: {"name": str, "path": str, "read_only": bool, "comment": str}
},
}

217
controller/lib/samba.py Normal file
View file

@ -0,0 +1,217 @@
"""
Samba share management for WROLPi Controller.
Manages smb.conf generation, smbd/nmbd service control, and share CRUD.
"""
import enum
import logging
import platform
import re
import subprocess
from pathlib import Path
from controller.lib.config import (
get_config_value,
get_media_directory,
is_docker_mode,
save_config,
update_config,
)
logger = logging.getLogger(__name__)
SMB_CONF_PATH = Path("/etc/samba/smb.conf")
# Share name must be alphanumeric with hyphens, underscores, or spaces.
SHARE_NAME_RE = re.compile(r'^[\w\s-]+$')
class SambaStatus(enum.Enum):
running = enum.auto()
stopped = enum.auto()
unavailable = enum.auto()
unknown = enum.auto()
def get_samba_status() -> SambaStatus:
"""Get Samba service status."""
if is_docker_mode():
return SambaStatus.unavailable
try:
result = subprocess.run(
["systemctl", "is-active", "smbd"],
capture_output=True, text=True, timeout=5,
)
active = result.stdout.strip()
if active == "active":
return SambaStatus.running
elif active in ("inactive", "dead"):
return SambaStatus.stopped
return SambaStatus.unknown
except FileNotFoundError:
return SambaStatus.unavailable
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
return SambaStatus.unknown
def get_samba_status_dict() -> dict:
"""Get Samba status as dict for API responses."""
status = get_samba_status()
shares = get_config_value("samba.shares", [])
reason = None
if status == SambaStatus.unavailable:
reason = "Samba not available (Docker mode or not installed)"
elif status == SambaStatus.unknown:
reason = "Could not determine Samba status"
return {
"enabled": status == SambaStatus.running,
"available": status != SambaStatus.unavailable,
"reason": reason,
"shares": shares,
}
def _generate_smb_conf() -> str:
"""Generate smb.conf content from config."""
shares = get_config_value("samba.shares", [])
media_dir = str(get_media_directory())
hostname = platform.node() or "WROLPi"
lines = [
"[global]",
" workgroup = WORKGROUP",
f" server string = {hostname}",
" security = user",
" map to guest = Bad User",
" guest account = nobody",
" log file = /var/log/samba/log.%m",
" max log size = 1000",
" logging = file",
" server role = standalone server",
"",
]
for share in shares:
name = share.get("name", "share")
path = share.get("path", media_dir)
read_only = "yes" if share.get("read_only", True) else "no"
comment = share.get("comment", "")
lines.extend([
f"[{name}]",
f" comment = {comment}",
f" path = {path}",
" browseable = yes",
f" read only = {read_only}",
" guest ok = yes",
" create mask = 0644",
" directory mask = 0755",
"",
])
return "\n".join(lines)
def _write_smb_conf() -> dict:
"""Write smb.conf and reload Samba config."""
try:
content = _generate_smb_conf()
SMB_CONF_PATH.write_text(content)
# Reload config without full restart if Samba is running.
if get_samba_status() == SambaStatus.running:
subprocess.run(
["smbcontrol", "all", "reload-config"],
capture_output=True, text=True, timeout=10,
)
return {"success": True}
except PermissionError:
return {"success": False, "error": "Permission denied writing smb.conf"}
except (subprocess.SubprocessError, OSError) as e:
return {"success": False, "error": str(e)}
def _validate_share_path(path: str) -> str | None:
"""Validate that a share path is under the media directory. Returns error message or None."""
media_dir = get_media_directory()
try:
share_path = Path(path).resolve()
except (ValueError, OSError):
return f"Invalid path: {path}"
if not str(share_path).startswith(str(media_dir.resolve())):
return f"Share path must be under {media_dir}"
if not share_path.is_dir():
return f"Path does not exist: {path}"
return None
def add_share(name: str, path: str, read_only: bool = True, comment: str = "") -> dict:
"""Add a Samba share."""
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
if not SHARE_NAME_RE.match(name):
return {"success": False, "error": "Share name must be alphanumeric (hyphens/underscores/spaces allowed)"}
path_error = _validate_share_path(path)
if path_error:
return {"success": False, "error": path_error}
shares = get_config_value("samba.shares", [])
# Check for duplicate name.
for s in shares:
if s["name"].lower() == name.lower():
return {"success": False, "error": f"Share '{name}' already exists"}
share_path = str(Path(path).resolve())
new_share = {"name": name, "path": share_path, "read_only": read_only, "comment": comment}
shares.append(new_share)
update_config("samba.shares", shares)
save_config()
# Write smb.conf so it's ready when user starts smbd.
_write_smb_conf()
return {"success": True, "share": new_share}
def remove_share(name: str) -> dict:
"""Remove a Samba share by name."""
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
shares = get_config_value("samba.shares", [])
new_shares = [s for s in shares if s["name"].lower() != name.lower()]
if len(new_shares) == len(shares):
return {"success": False, "error": f"Share '{name}' not found"}
update_config("samba.shares", new_shares)
save_config()
_write_smb_conf()
return {"success": True}
def apply_samba_from_config():
"""Apply Samba config on startup. Called from lifespan.
Writes smb.conf whenever shares are configured so the config is ready
when the user starts smbd from the services table.
"""
if is_docker_mode():
return
shares = get_config_value("samba.shares", [])
if shares:
result = _write_smb_conf()
if result["success"]:
logger.info("Applied Samba config with %d shares", len(shares))
else:
logger.warning("Failed to apply Samba config: %s", result.get("error"))

View file

@ -77,6 +77,10 @@ async def lifespan(app: FastAPI):
# Apply timezone from wrolpi.yaml to the system.
from controller.lib.admin import apply_timezone_from_config
apply_timezone_from_config()
# Apply Samba config if enabled.
from controller.lib.samba import apply_samba_from_config
apply_samba_from_config()
else:
# Drive may be mounted but controller.yaml may not exist yet (e.g., the system
# was set up before the Controller was introduced). Create the marker file.
@ -94,6 +98,9 @@ async def lifespan(app: FastAPI):
from controller.lib.admin import apply_timezone_from_config
apply_timezone_from_config()
from controller.lib.samba import apply_samba_from_config
apply_samba_from_config()
else:
logger.info("Primary drive not mounted - using default configuration")

View file

@ -773,7 +773,7 @@
<body>
{% if docker_mode %}
<div class="warning-banner">
Running in Docker mode. Disk mounting, hotspot, Bluetooth, and shutdown are not available.
Running in Docker mode. Disk mounting, hotspot, Bluetooth, Samba, and shutdown are not available.
</div>
{% endif %}

View file

@ -57,7 +57,7 @@ class TestManagedServicesConfig:
def test_managed_services_count(self):
"""Should have expected number of managed services."""
services = DEFAULT_CONFIG["managed_services"]
assert len(services) == 12
assert len(services) == 14
@pytest.mark.parametrize("name,expected", [
("wrolpi-api",

View file

@ -0,0 +1,204 @@
"""
Unit tests for controller.lib.samba module.
"""
import subprocess
from unittest import mock
from controller.lib.samba import (
SambaStatus,
_generate_smb_conf,
add_share,
apply_samba_from_config,
get_samba_status,
get_samba_status_dict,
remove_share,
)
class TestGetSambaStatus:
"""Tests for get_samba_status function."""
def test_returns_unavailable_in_docker_mode(self):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=True):
assert get_samba_status() == SambaStatus.unavailable
def test_returns_running_when_active(self):
mock_result = mock.Mock()
mock_result.stdout = "active\n"
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", return_value=mock_result):
assert get_samba_status() == SambaStatus.running
def test_returns_stopped_when_inactive(self):
mock_result = mock.Mock()
mock_result.stdout = "inactive\n"
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", return_value=mock_result):
assert get_samba_status() == SambaStatus.stopped
def test_returns_unavailable_when_systemctl_not_found(self):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
assert get_samba_status() == SambaStatus.unavailable
def test_returns_unknown_on_timeout(self):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 5)):
assert get_samba_status() == SambaStatus.unknown
class TestGetSambaStatusDict:
"""Tests for get_samba_status_dict function."""
def test_returns_expected_fields(self):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=True):
result = get_samba_status_dict()
assert "enabled" in result
assert "available" in result
assert "shares" in result
def test_unavailable_in_docker(self):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=True):
result = get_samba_status_dict()
assert result["available"] is False
assert result["reason"] is not None
class TestGenerateSmbConf:
"""Tests for _generate_smb_conf function."""
def test_generates_global_section(self, reset_runtime_config):
conf = _generate_smb_conf()
assert "[global]" in conf
assert "workgroup = WORKGROUP" in conf
assert "map to guest = Bad User" in conf
def test_generates_share_sections(self, reset_runtime_config):
from controller.lib.config import update_config
update_config("samba.shares", [
{"name": "TestShare", "path": "/media/wrolpi/test", "read_only": True, "comment": "Test"},
])
conf = _generate_smb_conf()
assert "[TestShare]" in conf
assert "path = /media/wrolpi/test" in conf
assert "read only = yes" in conf
assert "guest ok = yes" in conf
def test_read_write_share(self, reset_runtime_config):
from controller.lib.config import update_config
update_config("samba.shares", [
{"name": "Writable", "path": "/media/wrolpi", "read_only": False, "comment": ""},
])
conf = _generate_smb_conf()
assert "read only = no" in conf
def test_empty_shares_produces_global_only(self, reset_runtime_config):
conf = _generate_smb_conf()
assert "[global]" in conf
# No share sections.
lines = conf.split("\n")
sections = [l for l in lines if l.startswith("[") and l != "[global]"]
assert len(sections) == 0
class TestAddShare:
"""Tests for add_share function."""
def test_fails_in_docker_mode(self):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=True):
result = add_share("test", "/media/wrolpi")
assert result["success"] is False
def test_rejects_invalid_name(self, reset_runtime_config):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
result = add_share("test/bad", "/media/wrolpi")
assert result["success"] is False
assert "alphanumeric" in result["error"]
def test_rejects_path_outside_media_dir(self, reset_runtime_config, test_directory):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("controller.lib.samba.get_media_directory", return_value=test_directory):
result = add_share("test", "/tmp")
assert result["success"] is False
assert "must be under" in result["error"]
def test_rejects_nonexistent_path(self, reset_runtime_config, test_directory):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("controller.lib.samba.get_media_directory", return_value=test_directory):
result = add_share("test", str(test_directory / "nonexistent"))
assert result["success"] is False
assert "does not exist" in result["error"]
def test_rejects_duplicate_name(self, reset_runtime_config, test_directory):
from controller.lib.config import update_config
update_config("samba.shares", [
{"name": "existing", "path": str(test_directory), "read_only": True, "comment": ""},
])
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("controller.lib.samba.get_media_directory", return_value=test_directory):
with mock.patch("controller.lib.samba.save_config"):
with mock.patch("controller.lib.samba._write_smb_conf", return_value={"success": True}):
result = add_share("Existing", str(test_directory))
assert result["success"] is False
assert "already exists" in result["error"]
def test_adds_share_successfully(self, reset_runtime_config, test_directory):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("controller.lib.samba.get_media_directory", return_value=test_directory):
with mock.patch("controller.lib.samba.save_config"):
with mock.patch("controller.lib.samba._write_smb_conf", return_value={"success": True}):
result = add_share("MyShare", str(test_directory), read_only=False, comment="My files")
assert result["success"] is True
assert result["share"]["name"] == "MyShare"
assert result["share"]["read_only"] is False
class TestRemoveShare:
"""Tests for remove_share function."""
def test_removes_share(self, reset_runtime_config):
from controller.lib.config import update_config
update_config("samba.shares", [
{"name": "test", "path": "/media/wrolpi", "read_only": True, "comment": ""},
])
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("controller.lib.samba.save_config"):
with mock.patch("controller.lib.samba._write_smb_conf", return_value={"success": True}):
result = remove_share("test")
assert result["success"] is True
from controller.lib.config import get_config_value
assert len(get_config_value("samba.shares", [])) == 0
def test_returns_error_for_missing_share(self, reset_runtime_config):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
result = remove_share("nonexistent")
assert result["success"] is False
assert "not found" in result["error"]
class TestApplySambaFromConfig:
"""Tests for apply_samba_from_config function."""
def test_skips_in_docker_mode(self):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=True):
with mock.patch("controller.lib.samba._write_smb_conf") as mock_write:
apply_samba_from_config()
mock_write.assert_not_called()
def test_writes_config_when_shares_exist(self, reset_runtime_config):
from controller.lib.config import update_config
update_config("samba.shares", [
{"name": "test", "path": "/media/wrolpi", "read_only": True, "comment": ""},
])
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("controller.lib.samba._write_smb_conf", return_value={"success": True}) as mock_write:
apply_samba_from_config()
mock_write.assert_called_once()
def test_skips_when_no_shares(self, reset_runtime_config):
with mock.patch("controller.lib.samba.is_docker_mode", return_value=False):
with mock.patch("controller.lib.samba._write_smb_conf") as mock_write:
apply_samba_from_config()
mock_write.assert_not_called()

View file

@ -0,0 +1,55 @@
"""
Integration tests for Controller Samba API endpoints.
"""
class TestSambaStatusEndpoint:
"""Tests for /api/samba/status endpoint."""
def test_returns_200(self, test_client):
response = test_client.get("/api/samba/status")
assert response.status_code == 200
def test_returns_expected_fields(self, test_client):
response = test_client.get("/api/samba/status")
data = response.json()
assert "enabled" in data
assert "available" in data
assert "shares" in data
def test_shows_unavailable_in_docker(self, test_client_docker_mode):
response = test_client_docker_mode.get("/api/samba/status")
data = response.json()
assert data["available"] is False
class TestSambaShareAddEndpoint:
"""Tests for POST /api/samba/shares endpoint."""
def test_rejects_invalid_name(self, test_client):
response = test_client.post("/api/samba/shares", json={
"name": "bad/name",
"path": "/media/wrolpi",
})
assert response.status_code == 400
def test_fails_in_docker(self, test_client_docker_mode):
response = test_client_docker_mode.post("/api/samba/shares", json={
"name": "test",
"path": "/media/wrolpi",
})
assert response.status_code == 400
class TestSambaShareRemoveEndpoint:
"""Tests for DELETE /api/samba/shares/{name} endpoint."""
def test_returns_404_for_missing_share(self, test_client):
response = test_client.delete("/api/samba/shares/nonexistent")
assert response.status_code == 404
def test_fails_in_docker(self, test_client_docker_mode):
response = test_client_docker_mode.delete("/api/samba/shares/test")
assert response.status_code == 404

View file

@ -88,6 +88,7 @@ python3-pip
python3-venv
renderd
rsync
samba
smartmontools
software-properties-common
sudo

View file

@ -87,6 +87,7 @@ python3-pip
python3-venv
renderd
rsync
samba
smartmontools
software-properties-common
sysstat

View file

@ -114,5 +114,12 @@ if ! command -v caddy &>/dev/null; then
apt-get install -y caddy
fi
# Install Samba if not already installed.
if ! command -v smbd &>/dev/null; then
echo "Installing Samba..."
apt-get update
apt-get install -y samba
fi
# Install any configs, restart services.
/opt/wrolpi/repair.sh