Driver rename and icons: `Driver.display_name` plus a `DriverIcon` table, both global/shared like the `Driver` row they hang off, so a rename or an icon is what every Owner sees. The rename/icon dialog keeps its forms as siblings (nested forms are invalid HTML) and the icon routes return an `hx-swap-oob` thumbnail refresh rather than re-rendering the table, which would tear the open `<dialog>` out of the DOM. Web image picker: `GET /web/images` renders a pickable grid for a printer or a driver icon, with the search term prefilled from the entity name and editable. Picking one downloads it server-side and normalizes it. Driver download search: `GET /web/drivers` searches for a vendor-wide driver (the term is rewritten into the vendor's real product name for 15 brands) or for the exact model as typed. Links only — nothing is downloaded, and the fragment says the results are unvetted. Icon uploads no longer reject off-size or non-PNG files: `normalize_icon()` letterboxes any decodable raster into a 256x256 PNG. An already-exact 256x256 PNG is returned byte-identical, because icon storage is content-addressed and re-encoding would move the file on every save. `fetch_image()` makes the request from the server, so `assert_fetchable()` refuses any URL resolving to a private, loopback, or link-local address, and re-runs on every redirect. ImpTune sits on the same LAN as the printers it configures; an unguarded fetcher would be a port scanner for anyone who can reach the UI. DuckDuckGo is scraped, not called through an API — no key needed, but fragile, so both search functions swallow parse failures and return [] instead of 500ing a page. `WEB_SEARCH=false` disables every outbound request and hides the controls, for air-gapped installs. Also: one shared `Jinja2Templates` in `templating.py` instead of five per-router instances, so a template global is declared once; `_add_missing_columns()` in `database.py` adds new nullable columns to a pre-existing table, which `create_tables(safe=True)` skips; `db_env` in test_db.py now closes its connection on teardown, or the next test's ORM writes land in the previous test's DB file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
268 lines
9.8 KiB
Python
268 lines
9.8 KiB
Python
"""/web/images and /web/drivers — the HTMX fragments behind the search UIs.
|
|
|
|
`websearch.search_images` / `search_pages` are monkeypatched throughout: these
|
|
tests are about routing, ownership, and rendering, not about the scrape.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from imptune.services.websearch import ImageHit, PageHit
|
|
|
|
|
|
@pytest.fixture
|
|
def stub_images(monkeypatch):
|
|
"""Record the query the route asked for, return two canned hits."""
|
|
calls: list[tuple[str, int]] = []
|
|
|
|
def fake(query, limit=12):
|
|
calls.append((query, limit))
|
|
return [
|
|
ImageHit(
|
|
url="https://cdn.example.com/a.png",
|
|
thumbnail="https://tn.example.com/a.png",
|
|
title="Printer A",
|
|
width=800,
|
|
height=600,
|
|
source="cdn.example.com",
|
|
),
|
|
ImageHit(
|
|
url="https://cdn.example.com/b.jpg",
|
|
thumbnail="https://tn.example.com/b.jpg",
|
|
title="Printer B",
|
|
width=400,
|
|
height=400,
|
|
source="cdn.example.com",
|
|
),
|
|
]
|
|
|
|
from imptune.api import web
|
|
|
|
monkeypatch.setattr(web.websearch, "search_images", fake)
|
|
return calls
|
|
|
|
|
|
@pytest.fixture
|
|
def stub_pages(monkeypatch):
|
|
calls: list[tuple[str, int]] = []
|
|
|
|
def fake(query, limit=8):
|
|
calls.append((query, limit))
|
|
return [
|
|
PageHit(
|
|
url="https://support.hp.com/upd",
|
|
title="HP Universal Print Driver",
|
|
snippet="Download the UPD for Windows.",
|
|
host="support.hp.com",
|
|
)
|
|
]
|
|
|
|
from imptune.api import web
|
|
|
|
monkeypatch.setattr(web.websearch, "search_pages", fake)
|
|
return calls
|
|
|
|
|
|
def _printer(owner):
|
|
from imptune.db.models import Printer
|
|
|
|
return Printer.create(
|
|
name="HP LaserJet M404",
|
|
ip_address="10.0.0.7",
|
|
port_name="IP_10.0.0.7",
|
|
owner=owner,
|
|
)
|
|
|
|
|
|
def _driver():
|
|
from imptune.db.models import Driver
|
|
|
|
return Driver.create(
|
|
sha256="c" * 64,
|
|
original_filename="hp_upd.zip",
|
|
size_bytes=1024,
|
|
driver_desc=json.dumps(["HP Universal Printing PCL 6"]),
|
|
)
|
|
|
|
|
|
class TestImageSearchRoute:
|
|
def test_renders_pickable_results_for_a_printer(self, client, owner, tmp_data_dir, stub_images):
|
|
printer = _printer(owner)
|
|
response = client.get(
|
|
"/web/images", params={"q": "hp m404 printer", "target": "printer", "id": printer.id}
|
|
)
|
|
assert response.status_code == 200
|
|
assert stub_images == [("hp m404 printer", 12)]
|
|
assert f'hx-post="/printers/{printer.id}/icon/from-web"' in response.text
|
|
assert 'hx-target="#icon-status"' in response.text
|
|
assert "https://cdn.example.com/a.png" in response.text
|
|
# Thumbnails load from the engine's CDN — no referrer leak.
|
|
assert 'referrerpolicy="no-referrer"' in response.text
|
|
|
|
def test_renders_pickable_results_for_a_driver(self, client, owner, tmp_data_dir, stub_images):
|
|
driver = _driver()
|
|
response = client.get(
|
|
"/web/images", params={"q": "hp upd", "target": "driver", "id": driver.id}
|
|
)
|
|
assert response.status_code == 200
|
|
assert f'hx-post="/drivers/{driver.id}/icon/from-web"' in response.text
|
|
assert f'hx-target="#driver-icon-status-{driver.id}"' in response.text
|
|
|
|
def test_another_owners_printer_reads_as_missing(self, client, owner, tmp_data_dir, stub_images):
|
|
"""404, not 403 — and no search is spent on it."""
|
|
from imptune.db.models import Owner, Printer
|
|
from imptune.services.session import generate_key
|
|
|
|
stranger = Owner.create(key=generate_key())
|
|
theirs = Printer.create(
|
|
name="Theirs", ip_address="10.0.0.9", port_name="IP_10_0_0_9", owner=stranger
|
|
)
|
|
response = client.get(
|
|
"/web/images", params={"q": "x", "target": "printer", "id": theirs.id}
|
|
)
|
|
assert response.status_code == 404
|
|
assert stub_images == []
|
|
|
|
def test_unknown_driver_is_404(self, client, tmp_data_dir, stub_images):
|
|
response = client.get(
|
|
"/web/images", params={"q": "x", "target": "driver", "id": 99999}
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
def test_unknown_target_is_400(self, client, tmp_data_dir, stub_images):
|
|
response = client.get("/web/images", params={"q": "x", "target": "wat", "id": 1})
|
|
assert response.status_code == 400
|
|
|
|
def test_blank_query_does_not_search(self, client, owner, tmp_data_dir, stub_images):
|
|
printer = _printer(owner)
|
|
response = client.get(
|
|
"/web/images", params={"q": " ", "target": "printer", "id": printer.id}
|
|
)
|
|
assert response.status_code == 200
|
|
assert stub_images == []
|
|
|
|
def test_no_results_renders_a_hint(self, client, owner, tmp_data_dir, monkeypatch):
|
|
from imptune.api import web
|
|
|
|
monkeypatch.setattr(web.websearch, "search_images", lambda q, limit=12: [])
|
|
printer = _printer(owner)
|
|
response = client.get(
|
|
"/web/images", params={"q": "zzz", "target": "printer", "id": printer.id}
|
|
)
|
|
assert response.status_code == 200
|
|
assert "No results" in response.text
|
|
|
|
def test_disabled_deployment_says_so(self, client, owner, tmp_data_dir, monkeypatch):
|
|
import imptune.config as cfg
|
|
|
|
monkeypatch.setattr(cfg, "WEB_SEARCH", False)
|
|
printer = _printer(owner)
|
|
response = client.get(
|
|
"/web/images", params={"q": "x", "target": "printer", "id": printer.id}
|
|
)
|
|
assert response.status_code == 200
|
|
assert "disabled" in response.text
|
|
|
|
|
|
class TestDriverSearchRoute:
|
|
def test_generic_mode_rewrites_the_query(self, client, tmp_data_dir, stub_pages):
|
|
response = client.get(
|
|
"/web/drivers", params={"q": "HP LaserJet M404dn", "mode": "generic"}
|
|
)
|
|
assert response.status_code == 200
|
|
assert stub_pages == [("HP Universal Print Driver PCL6 download", 12)]
|
|
assert "support.hp.com/upd" in response.text
|
|
|
|
def test_exact_mode_searches_the_text_as_typed(self, client, tmp_data_dir, stub_pages):
|
|
response = client.get(
|
|
"/web/drivers", params={"q": "HP LaserJet M404dn", "mode": "exact"}
|
|
)
|
|
assert stub_pages == [("HP LaserJet M404dn", 12)]
|
|
|
|
def test_results_carry_the_unvetted_warning(self, client, tmp_data_dir, stub_pages):
|
|
"""The links are raw search results — the fragment must say so."""
|
|
response = client.get("/web/drivers", params={"q": "hp", "mode": "generic"})
|
|
assert "driver_search_warning" in response.text
|
|
assert 'rel="noopener noreferrer nofollow"' in response.text
|
|
assert 'target="_blank"' in response.text
|
|
|
|
def test_blank_query_does_not_search(self, client, tmp_data_dir, stub_pages):
|
|
response = client.get("/web/drivers", params={"q": " "})
|
|
assert response.status_code == 200
|
|
assert stub_pages == []
|
|
|
|
def test_disabled_deployment_says_so(self, client, tmp_data_dir, monkeypatch):
|
|
import imptune.config as cfg
|
|
|
|
monkeypatch.setattr(cfg, "WEB_SEARCH", False)
|
|
response = client.get("/web/drivers", params={"q": "hp"})
|
|
assert "disabled" in response.text
|
|
|
|
|
|
class TestSearchUiVisibility:
|
|
def test_drivers_page_offers_the_search_card(self, client, tmp_data_dir):
|
|
page = client.get("/drivers")
|
|
assert 'hx-get="/web/drivers"' in page.text
|
|
assert "Konica Minolta" in page.text # brand datalist
|
|
|
|
def test_printer_detail_offers_the_image_picker(self, client, owner, tmp_data_dir):
|
|
printer = _printer(owner)
|
|
page = client.get(f"/printers/{printer.id}")
|
|
assert 'hx-get="/web/images"' in page.text
|
|
# Prefilled from the printer name, so one click is enough.
|
|
assert 'value="HP LaserJet M404 printer"' in page.text
|
|
|
|
def test_every_search_control_disappears_when_disabled(
|
|
self, client, owner, tmp_data_dir, monkeypatch
|
|
):
|
|
import imptune.config as cfg
|
|
|
|
monkeypatch.setattr(cfg, "WEB_SEARCH", False)
|
|
printer = _printer(owner)
|
|
|
|
assert 'hx-get="/web/drivers"' not in client.get("/drivers").text
|
|
assert 'hx-get="/web/images"' not in client.get(f"/printers/{printer.id}").text
|
|
|
|
|
|
def test_picked_image_becomes_the_printer_icon(client, owner, tmp_data_dir, monkeypatch):
|
|
"""End to end through the picker's POST: download, normalize, store, preview."""
|
|
from imptune.db.models import Icon
|
|
from imptune.services import websearch
|
|
|
|
printer = _printer(owner)
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", (500, 300), color="orange").save(buf, format="JPEG")
|
|
monkeypatch.setattr(websearch, "fetch_image", lambda url: buf.getvalue())
|
|
|
|
response = client.post(
|
|
f"/printers/{printer.id}/icon/from-web",
|
|
data={"url": "https://cdn.example.com/photos/m404.jpg"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert f'src="/printers/{printer.id}/icon?v=' in response.text
|
|
|
|
icon = Icon.get(Icon.printer == printer.id)
|
|
with Image.open(tmp_data_dir / "icons" / icon.sha256) as img:
|
|
assert img.format == "PNG"
|
|
assert img.size == (256, 256)
|
|
|
|
|
|
def test_from_web_rejects_an_unowned_printer(client, tmp_data_dir, monkeypatch):
|
|
from imptune.db.models import Owner, Printer
|
|
from imptune.services import websearch
|
|
from imptune.services.session import generate_key
|
|
|
|
monkeypatch.setattr(websearch, "fetch_image", lambda url: b"unused")
|
|
stranger = Owner.create(key=generate_key())
|
|
theirs = Printer.create(
|
|
name="Theirs", ip_address="10.0.0.9", port_name="IP_10_0_0_9", owner=stranger
|
|
)
|
|
response = client.post(
|
|
f"/printers/{theirs.id}/icon/from-web", data={"url": "https://example.com/x.png"}
|
|
)
|
|
assert response.status_code == 404
|