fix: firmware upload only for idle printers (dialog, server, re-check after download, queue waits during upload); AMS Reset Slot asks first and is refused while printing; confirms for shopping list Clear all, Git history Clear and SSO unlink (last SSO link of an account without password stays); LDAP/SMTP test use the form's values; emptying a user's email removes it; local backup delete errors are shown; removed the unreachable printer-card Spoolman link/unlink

This commit is contained in:
maziggy
2026-10-20 21:35:41 +02:00
parent 57ed8253ea
commit 770c6bfdf9
49 changed files with 1216 additions and 266 deletions
@@ -0,0 +1,188 @@
"""The LDAP and SMTP "Test" buttons test what the form holds, not only what was saved.
A password left empty in the form keeps the saved one, which still only goes
to the server it was entered for.
"""
from unittest.mock import patch
import pytest
from httpx import AsyncClient
LDAP_SAVED = {
"ldap_server_url": "ldaps://ldap.example.com",
"ldap_bind_dn": "cn=svc,dc=example,dc=com",
"ldap_bind_password": "saved-bind-secret",
"ldap_search_base": "dc=example,dc=com",
"ldap_security": "ldaps",
}
SMTP_SAVED = {
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_username": "mailer@example.com",
"smtp_password": "saved-smtp-secret",
"smtp_security": "starttls",
"smtp_auth_enabled": True,
"smtp_from_email": "noreply@example.com",
}
async def _ldap_test(async_client: AsyncClient, body: dict | None):
tested = []
def fake_test(config):
tested.append(config)
return True, "ok"
with patch("backend.app.services.ldap_service.test_ldap_connection", side_effect=fake_test):
if body is None:
response = await async_client.post("/api/v1/auth/ldap/test")
else:
response = await async_client.post("/api/v1/auth/ldap/test", json=body)
assert response.status_code == 200, response.text
return response.json(), tested
@pytest.fixture
async def ldap_saved(async_client: AsyncClient):
response = await async_client.put("/api/v1/settings/", json=LDAP_SAVED)
assert response.status_code == 200, response.text
class TestLdap:
@pytest.mark.asyncio
async def test_tests_the_form_values(self, async_client, ldap_saved):
result, tested = await _ldap_test(
async_client,
{
**LDAP_SAVED,
"ldap_bind_dn": "cn=other,dc=example,dc=com",
"ldap_bind_password": "typed-secret",
"ldap_search_base": "ou=people,dc=example,dc=com",
},
)
assert result["success"] is True
assert tested[0].bind_dn == "cn=other,dc=example,dc=com"
assert tested[0].bind_password == "typed-secret"
assert tested[0].search_base == "ou=people,dc=example,dc=com"
@pytest.mark.asyncio
async def test_an_empty_password_keeps_the_saved_one_for_the_same_server(self, async_client, ldap_saved):
_, tested = await _ldap_test(async_client, {**LDAP_SAVED, "ldap_bind_password": ""})
assert tested[0].bind_password == "saved-bind-secret"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"changed",
[{"ldap_server_url": "ldaps://elsewhere.example.net"}, {"ldap_security": "starttls"}],
)
async def test_the_saved_password_never_goes_to_another_server(self, async_client, ldap_saved, changed):
result, tested = await _ldap_test(async_client, {**LDAP_SAVED, "ldap_bind_password": "", **changed})
assert result["success"] is False
assert "again" in result["message"]
assert tested == []
@pytest.mark.asyncio
async def test_without_a_body_it_tests_the_saved_settings(self, async_client, ldap_saved):
_, tested = await _ldap_test(async_client, None)
assert tested[0].server_url == "ldaps://ldap.example.com"
assert tested[0].bind_password == "saved-bind-secret"
@pytest.mark.asyncio
async def test_a_saved_ca_certificate_is_used_while_ldap_is_off(self, async_client, db_session, ldap_saved):
from backend.app.models.settings import Settings
db_session.add(Settings(key="ldap_ca_cert_path", value="/certs/ca.pem"))
await db_session.commit()
_, tested = await _ldap_test(async_client, {**LDAP_SAVED, "ldap_bind_password": ""})
assert tested[0].ca_cert_path == "/certs/ca.pem"
@pytest.mark.asyncio
async def test_ldap_unset_security_is_starttls_as_when_saving(async_client, db_session):
"""Settings saved without a security row: the form's StartTLS is no change of destination."""
from backend.app.models.settings import Settings
for key, value in {**LDAP_SAVED, "ldap_server_url": "ldap://ldap.example.com"}.items():
if key != "ldap_security":
db_session.add(Settings(key=key, value=value))
await db_session.commit()
result, tested = await _ldap_test(
async_client,
{
**LDAP_SAVED,
"ldap_server_url": "ldap://ldap.example.com",
"ldap_security": "starttls",
"ldap_bind_password": "",
},
)
assert result["success"] is True, result
assert tested[0].bind_password == "saved-bind-secret"
async def _smtp_test(async_client: AsyncClient, smtp: dict | None):
body: dict = {"test_recipient": "someone@example.com"}
if smtp is not None:
body["smtp"] = smtp
with patch("backend.app.api.routes.auth.send_email") as send:
response = await async_client.post("/api/v1/auth/smtp/test", json=body)
assert response.status_code == 200, response.text
return response.json(), send
@pytest.fixture
async def smtp_saved(async_client: AsyncClient):
response = await async_client.post("/api/v1/auth/smtp", json=SMTP_SAVED)
assert response.status_code == 200, response.text
class TestSmtp:
@pytest.mark.asyncio
async def test_tests_the_form_values(self, async_client, smtp_saved):
result, send = await _smtp_test(
async_client, {**SMTP_SAVED, "smtp_from_email": "typed@example.com", "smtp_password": "typed-secret"}
)
assert result["success"] is True
used = send.call_args.kwargs["smtp_settings"]
assert used.smtp_from_email == "typed@example.com"
assert used.smtp_password == "typed-secret"
@pytest.mark.asyncio
async def test_an_empty_password_keeps_the_saved_one_for_the_same_server(self, async_client, smtp_saved):
_, send = await _smtp_test(async_client, {**SMTP_SAVED, "smtp_password": ""})
assert send.call_args.kwargs["smtp_settings"].smtp_password == "saved-smtp-secret"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"changed", [{"smtp_host": "mail.elsewhere.example.net"}, {"smtp_port": 2525}, {"smtp_security": "ssl"}]
)
async def test_the_saved_password_never_goes_to_another_server(self, async_client, smtp_saved, changed):
result, send = await _smtp_test(async_client, {**SMTP_SAVED, "smtp_password": "", **changed})
assert result["success"] is False
assert "again" in result["message"]
send.assert_not_called()
@pytest.mark.asyncio
async def test_without_the_form_it_tests_the_saved_settings(self, async_client, smtp_saved):
_, send = await _smtp_test(async_client, None)
assert send.call_args.kwargs["smtp_settings"].smtp_host == "smtp.example.com"
@pytest.mark.asyncio
async def test_the_form_works_before_anything_was_saved(self, async_client):
result, send = await _smtp_test(async_client, SMTP_SAVED)
assert result["success"] is True
assert send.call_args.kwargs["smtp_settings"].smtp_password == "saved-smtp-secret"
@@ -0,0 +1,177 @@
"""Actions that only an idle printer takes: firmware upload and AMS slot reset.
Firmware goes onto the SD card the printer prints from, and a slot reset
clears the filament setting of a tray a running print may be feeding from.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.firmware_update import FirmwareUpdateService, get_upload_state, reset_upload_state
def _client(state: str) -> MagicMock:
client = MagicMock()
client.state = SimpleNamespace(state=state, firmware_version="01.06.00.00", sdcard=True, connected=True)
client.reset_ams_slot.return_value = True
return client
def _firmware_service() -> MagicMock:
service = MagicMock()
service.check_for_update = AsyncMock(return_value={"update_available": True, "latest_version": "01.07.00.00"})
service.get_firmware_file_info = AsyncMock(return_value={"filename": "fw.bin"})
service.download_firmware = AsyncMock()
service.get_latest_version = AsyncMock(return_value=None)
return service
def _patched(client: MagicMock, service: MagicMock):
return (
patch("backend.app.services.firmware_update.printer_manager.get_client", return_value=client),
patch("backend.app.services.firmware_update.get_firmware_service", return_value=service),
patch(
"backend.app.services.firmware_update.get_storage_info_async",
AsyncMock(return_value={"free_bytes": 10 * 1024**3}),
),
)
class TestFirmwareUpload:
@pytest.mark.asyncio
@pytest.mark.parametrize("state", ["RUNNING", "PAUSE", "PREPARE", "SLICING", "unknown"])
async def test_a_busy_printer_is_refused_in_the_prepare_check(self, async_client, printer_factory, state):
printer = await printer_factory()
a, b, c = _patched(_client(state), _firmware_service())
with a, b, c:
response = await async_client.get(f"/api/v1/firmware/updates/{printer.id}/prepare")
assert response.status_code == 200
body = response.json()
assert body["can_proceed"] is False
assert any("idle" in error for error in body["errors"])
@pytest.mark.asyncio
@pytest.mark.parametrize("state", ["IDLE", "FINISH", "FAILED"])
async def test_an_idle_printer_passes_the_prepare_check(self, async_client, printer_factory, state):
printer = await printer_factory()
a, b, c = _patched(_client(state), _firmware_service())
with a, b, c:
response = await async_client.get(f"/api/v1/firmware/updates/{printer.id}/prepare")
assert response.json()["can_proceed"] is True, response.json()["errors"]
@pytest.mark.asyncio
async def test_the_upload_route_refuses_a_printing_printer(self, async_client, printer_factory):
printer = await printer_factory()
a, b, c = _patched(_client("RUNNING"), _firmware_service())
with (
a,
b,
c,
patch.object(FirmwareUpdateService, "start_upload", AsyncMock(return_value=True)) as start,
):
response = await async_client.post(f"/api/v1/firmware/updates/{printer.id}/upload")
assert response.status_code == 400
assert "idle" in response.json()["detail"]
start.assert_not_called()
@pytest.mark.asyncio
async def test_a_print_started_during_the_download_gets_no_firmware(self, tmp_path):
"""The download can take minutes; the card is checked again before writing to it."""
client = _client("IDLE")
service = _firmware_service()
firmware = tmp_path / "fw.bin"
firmware.write_bytes(b"x")
async def download(*_args, **_kwargs):
client.state.state = "RUNNING"
return firmware
service.download_firmware = AsyncMock(side_effect=download)
upload = AsyncMock(return_value=True)
reset_upload_state(9101)
with (
patch("backend.app.services.firmware_update.printer_manager.get_client", return_value=client),
patch("backend.app.services.firmware_update.get_firmware_service", return_value=service),
patch("backend.app.services.firmware_update.upload_file_async", upload),
patch(
"backend.app.services.firmware_update.get_ftp_retry_settings",
AsyncMock(return_value=(False, 0, 0, 30)),
),
):
await FirmwareUpdateService()._do_upload(9101, "192.0.2.10", "12345678", "X1C", "01.07.00.00")
upload.assert_not_called()
state = get_upload_state(9101)
assert state.status.value == "error"
assert "idle" in (state.error or "")
reset_upload_state(9101)
class TestAmsSlotReset:
@pytest.mark.asyncio
@pytest.mark.parametrize("state", ["RUNNING", "PAUSE", "PREPARE", "SLICING"])
async def test_refused_while_a_print_is_loaded(self, async_client, printer_factory, state):
printer = await printer_factory()
client = _client(state)
with patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client):
response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/tray/1/reset")
assert response.status_code == 409
client.reset_ams_slot.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("state", ["IDLE", "FINISH", "FAILED"])
async def test_sent_when_no_print_is_loaded(self, async_client, printer_factory, state):
printer = await printer_factory()
client = _client(state)
with patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client):
response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/tray/1/reset")
assert response.status_code == 200
client.reset_ams_slot.assert_called_once_with(ams_id=0, tray_id=1)
class TestQueueDuringFirmwareUpload:
"""A queued print does not start while firmware is being put on the printer's card."""
@pytest.fixture
def idle_printer(self):
from backend.app.services.printer_manager import printer_manager
printer_id = 9102
client = MagicMock()
client.state = SimpleNamespace(state="IDLE", connected=True)
printer_manager._clients[printer_id] = client
reset_upload_state(printer_id)
try:
with (
patch.object(printer_manager, "is_connected", return_value=True),
patch.object(printer_manager, "get_status", return_value=client.state),
):
yield printer_id
finally:
printer_manager._clients.pop(printer_id, None)
reset_upload_state(printer_id)
@pytest.mark.parametrize("status", ["preparing", "downloading", "uploading"])
def test_not_idle_while_an_upload_runs(self, idle_printer, status):
from backend.app.services.firmware_update import FirmwareUploadStatus
from backend.app.services.print_scheduler import PrintScheduler
get_upload_state(idle_printer).status = FirmwareUploadStatus(status)
assert PrintScheduler()._is_printer_idle(idle_printer, require_plate_clear=False) is False
@pytest.mark.parametrize("status", ["idle", "complete", "error"])
def test_idle_again_once_it_is_over(self, idle_printer, status):
from backend.app.services.firmware_update import FirmwareUploadStatus
from backend.app.services.print_scheduler import PrintScheduler
get_upload_state(idle_printer).status = FirmwareUploadStatus(status)
assert PrintScheduler()._is_printer_idle(idle_printer, require_plate_clear=False) is True
@@ -0,0 +1,79 @@
"""Removing a linked SSO account never removes a user's last way to sign in.
A user Bambuddy created from an SSO login has no password, so their last
link stays; a user with a password, or with another link, can remove it.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from backend.app.core.auth import create_access_token, get_password_hash
from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
from backend.app.models.user import User
from backend.tests.integration.test_mfa_api import _setup_and_login
async def _provider(db_session, name: str) -> int:
provider = OIDCProvider(name=name, issuer_url=f"https://{name}.example.com", client_id="bambuddy")
provider.client_secret = "provider-secret"
db_session.add(provider)
await db_session.commit()
return provider.id
async def _user(db_session, *, oidc_only: bool, providers: list[int]) -> tuple[User, dict]:
user = User(
username="sso-user",
password_hash=None if oidc_only else get_password_hash("Whatever1!"),
auth_source="oidc" if oidc_only else "local",
role="user",
is_active=True,
)
db_session.add(user)
await db_session.commit()
for i, provider_id in enumerate(providers):
db_session.add(UserOIDCLink(user_id=user.id, provider_id=provider_id, provider_user_id=f"sub-{i}"))
await db_session.commit()
return user, {"Authorization": f"Bearer {create_access_token(data={'sub': user.username})}"}
async def _links(db_session, user_id: int) -> int:
db_session.expire_all()
return len((await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.user_id == user_id))).all())
@pytest.mark.asyncio
async def test_the_last_link_of_a_user_without_password_stays(async_client: AsyncClient, db_session):
await _setup_and_login(async_client, "ssoadmin", "SsoAdmin12!")
provider = await _provider(db_session, "alpha")
user, headers = await _user(db_session, oidc_only=True, providers=[provider])
response = await async_client.delete(f"/api/v1/auth/oidc/links/{provider}", headers=headers)
assert response.status_code == 400
assert await _links(db_session, user.id) == 1
@pytest.mark.asyncio
async def test_a_user_without_password_can_remove_one_of_two_links(async_client: AsyncClient, db_session):
await _setup_and_login(async_client, "ssoadmin", "SsoAdmin12!")
first, second = await _provider(db_session, "alpha"), await _provider(db_session, "beta")
user, headers = await _user(db_session, oidc_only=True, providers=[first, second])
response = await async_client.delete(f"/api/v1/auth/oidc/links/{first}", headers=headers)
assert response.status_code == 200, response.text
assert await _links(db_session, user.id) == 1
@pytest.mark.asyncio
async def test_a_user_with_a_password_can_remove_the_last_link(async_client: AsyncClient, db_session):
await _setup_and_login(async_client, "ssoadmin", "SsoAdmin12!")
provider = await _provider(db_session, "alpha")
user, headers = await _user(db_session, oidc_only=False, providers=[provider])
response = await async_client.delete(f"/api/v1/auth/oidc/links/{provider}", headers=headers)
assert response.status_code == 200, response.text
assert await _links(db_session, user.id) == 0
@@ -0,0 +1,102 @@
"""Editing a user can remove their email: an empty email clears it.
Left out or null, the email stays as it is. With advanced authentication on,
a user needs an email (password resets go there), so it can't be removed.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from backend.app.models.settings import Settings
from backend.app.models.user import User
async def _user(db_session, email: str | None = "mia@example.invalid") -> User:
from backend.app.core.auth import get_password_hash
user = User(username="mia", password_hash=get_password_hash("Whatever1!"), email=email, role="user", is_active=True)
db_session.add(user)
await db_session.commit()
return user
async def _email(db_session, user_id: int) -> str | None:
db_session.expire_all()
return (await db_session.execute(select(User.email).where(User.id == user_id))).scalar_one()
@pytest.mark.asyncio
@pytest.mark.parametrize("email", ["", " "])
async def test_an_empty_email_clears_it(async_client: AsyncClient, db_session, email):
user = await _user(db_session)
response = await async_client.patch(f"/api/v1/users/{user.id}", json={"email": email})
assert response.status_code == 200, response.text
assert response.json()["email"] is None
assert await _email(db_session, user.id) is None
@pytest.mark.asyncio
@pytest.mark.parametrize("body", [{}, {"email": None}, {"role": "user"}])
async def test_left_out_or_null_keeps_it(async_client: AsyncClient, db_session, body):
user = await _user(db_session)
response = await async_client.patch(f"/api/v1/users/{user.id}", json=body)
assert response.status_code == 200, response.text
assert await _email(db_session, user.id) == "mia@example.invalid"
@pytest.mark.asyncio
async def test_two_users_without_email_are_no_duplicate(async_client: AsyncClient, db_session):
from backend.app.core.auth import get_password_hash
db_session.add(User(username="other", password_hash=get_password_hash("Whatever1!"), email=None, role="user"))
user = await _user(db_session)
response = await async_client.patch(f"/api/v1/users/{user.id}", json={"email": ""})
assert response.status_code == 200, response.text
@pytest.mark.asyncio
async def test_refused_while_advanced_authentication_is_on(async_client: AsyncClient, db_session):
user = await _user(db_session)
db_session.add(Settings(key="advanced_auth_enabled", value="true"))
await db_session.commit()
response = await async_client.patch(f"/api/v1/users/{user.id}", json={"email": ""})
assert response.status_code == 400
assert "advanced authentication" in response.json()["detail"]
assert await _email(db_session, user.id) == "mia@example.invalid"
@pytest.mark.asyncio
async def test_a_user_without_email_can_still_be_edited_with_advanced_authentication(
async_client: AsyncClient, db_session
):
user = await _user(db_session, email=None)
db_session.add(Settings(key="advanced_auth_enabled", value="true"))
await db_session.commit()
response = await async_client.patch(f"/api/v1/users/{user.id}", json={"email": "", "role": "user"})
assert response.status_code == 200, response.text
assert await _email(db_session, user.id) is None
@pytest.mark.asyncio
async def test_refused_while_the_user_signs_in_with_an_email_code(async_client: AsyncClient, db_session):
"""Email 2FA needs SMTP, not advanced authentication: the code goes to this address."""
user = await _user(db_session)
db_session.add(Settings(key=f"user_{user.id}_email_2fa_enabled", value="true"))
await db_session.commit()
response = await async_client.patch(f"/api/v1/users/{user.id}", json={"email": ""})
assert response.status_code == 400
assert "email" in response.json()["detail"].lower()
assert await _email(db_session, user.id) == "mia@example.invalid"