feat: driver rename, driver icons, web image + driver search
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>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
"""E2E: the driver library's rename dialog — open, pre-fill, submit, row updates."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
# Same reasoning as test_printer_edit.py: the button label goes through the i18n
|
||||
# store, which follows navigator.language, so only a structural hook is portable.
|
||||
EDIT_BUTTON = "button[onclick*='showModal']"
|
||||
|
||||
INF = """[Version]
|
||||
Signature="$Windows NT$"
|
||||
Class=Printer
|
||||
Provider=%Vendor%
|
||||
|
||||
[Manufacturer]
|
||||
%Vendor%=Models,NTamd64
|
||||
|
||||
[Models.NTamd64]
|
||||
"E2E Rename Printer"=Install,USBPRINT\\E2E
|
||||
|
||||
[Strings]
|
||||
Vendor="E2E Vendor"
|
||||
"""
|
||||
|
||||
|
||||
def _driver_zip() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("e2e_rename.inf", INF)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _upload_driver(live_server: str, owner_key: str) -> None:
|
||||
import httpx
|
||||
|
||||
from imptune.services.session import COOKIE_NAME
|
||||
|
||||
with httpx.Client(
|
||||
base_url=live_server, follow_redirects=True, cookies={COOKIE_NAME: owner_key}
|
||||
) as api:
|
||||
response = api.post(
|
||||
"/drivers/upload",
|
||||
files={"file": ("e2e_rename_pkg.zip", _driver_zip(), "application/zip")},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
def test_driver_rename_dialog_prefills_the_zip_name(
|
||||
page, live_server: str, _e2e_owner_key: str
|
||||
) -> None:
|
||||
"""With no rename yet, the input is empty and the ZIP name is its placeholder."""
|
||||
_upload_driver(live_server, _e2e_owner_key)
|
||||
|
||||
page.goto(f"{live_server}/drivers", wait_until="domcontentloaded")
|
||||
row = page.locator("tr", has=page.locator("text=e2e_rename_pkg.zip"))
|
||||
row.first.wait_for()
|
||||
row.first.locator(EDIT_BUTTON).click()
|
||||
|
||||
page.wait_for_selector("dialog[open]")
|
||||
field = page.locator("dialog[open] input[name='display_name']")
|
||||
assert field.input_value() == ""
|
||||
assert field.get_attribute("placeholder") == "e2e_rename_pkg.zip"
|
||||
|
||||
|
||||
def test_driver_rename_updates_the_row(page, live_server: str, _e2e_owner_key: str) -> None:
|
||||
"""Saving swaps the table in place and shows the new label over the ZIP name."""
|
||||
_upload_driver(live_server, _e2e_owner_key)
|
||||
|
||||
page.goto(f"{live_server}/drivers", wait_until="domcontentloaded")
|
||||
row = page.locator("tr", has=page.locator("text=e2e_rename_pkg.zip"))
|
||||
row.first.wait_for()
|
||||
row.first.locator(EDIT_BUTTON).click()
|
||||
page.wait_for_selector("dialog[open]")
|
||||
|
||||
page.fill("dialog[open] input[name='display_name']", "Ground floor MFP")
|
||||
# The dialog holds several submit buttons (icon upload, web search); the
|
||||
# footer's Save is the one bound to the rename form.
|
||||
page.click("dialog[open] .dialog-foot button[type='submit']")
|
||||
|
||||
page.wait_for_selector("text=Ground floor MFP")
|
||||
assert page.locator("dialog[open]").count() == 0
|
||||
# Renaming does not hide what is actually stored on disk.
|
||||
assert page.locator("text=e2e_rename_pkg.zip").count() >= 1
|
||||
+46
-1
@@ -26,12 +26,18 @@ def db_env(tmp_path, monkeypatch):
|
||||
if not db.is_closed():
|
||||
db.close()
|
||||
|
||||
return {
|
||||
yield {
|
||||
"data_dir": data_dir,
|
||||
"drivers_dir": drivers_dir,
|
||||
"db_path": db_path,
|
||||
}
|
||||
|
||||
# A connection left open here stays bound to *this* test's file, and the next
|
||||
# test's ORM writes would land in it instead of its own tmp DB — the app
|
||||
# thread would then 404 on rows the test just created.
|
||||
if not db.is_closed():
|
||||
db.close()
|
||||
|
||||
|
||||
def test_create_tables(db_env):
|
||||
"""init_db() creates all 4 tables in a fresh SQLite file."""
|
||||
@@ -140,3 +146,42 @@ def test_driver_store_get_path(db_env):
|
||||
path = store.get_path(sha256)
|
||||
|
||||
assert path == Path(str(drivers_dir)) / f"{sha256}.zip"
|
||||
|
||||
|
||||
def test_init_db_adds_display_name_to_a_preexisting_driver_table(db_env):
|
||||
"""A DB created before the rename feature gains the column, keeping its rows.
|
||||
|
||||
`create_tables(safe=True)` skips a table that already exists, so a new field
|
||||
on an old model only lands through `_add_missing_columns`.
|
||||
"""
|
||||
from imptune.db.database import db, init_db
|
||||
|
||||
init_db()
|
||||
db.execute_sql("ALTER TABLE driver DROP COLUMN display_name")
|
||||
db.execute_sql(
|
||||
"INSERT INTO driver (sha256, original_filename, size_bytes, uploaded_at, "
|
||||
"has_cat_file) VALUES ('legacy', 'old.zip', 10, '2024-01-01 00:00:00', 0)"
|
||||
)
|
||||
db.close()
|
||||
|
||||
init_db()
|
||||
|
||||
columns = {row[1] for row in db.execute_sql("PRAGMA table_info(driver)")}
|
||||
assert "display_name" in columns
|
||||
|
||||
from imptune.db.models import Driver
|
||||
|
||||
legacy = Driver.get(Driver.sha256 == "legacy")
|
||||
assert legacy.display_name is None
|
||||
assert legacy.label == "old.zip"
|
||||
|
||||
|
||||
def test_init_db_creates_the_driver_icon_table(db_env):
|
||||
from imptune.db.database import db, init_db
|
||||
|
||||
init_db()
|
||||
tables = {
|
||||
row[0]
|
||||
for row in db.execute_sql("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
assert "driver_icon" in tables
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Driver icons — upload, fetch-from-web, and serve. Global, like Driver rows."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _png(width: int = 256, height: int = 256, color: str = "red") -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGBA", (width, height), color=color).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _create_driver(sha: str = "b" * 64):
|
||||
from imptune.db.models import Driver
|
||||
|
||||
return Driver.create(
|
||||
sha256=sha,
|
||||
original_filename="konica_c300i.zip",
|
||||
size_bytes=4096,
|
||||
driver_desc=json.dumps(["KONICA MINOLTA C300i PCL"]),
|
||||
inf_filename="kocpl.inf",
|
||||
architecture="amd64",
|
||||
)
|
||||
|
||||
|
||||
class TestDriverIconUpload:
|
||||
def test_upload_creates_the_record_and_file(self, client, tmp_data_dir):
|
||||
from imptune.db.models import DriverIcon
|
||||
|
||||
driver = _create_driver()
|
||||
response = client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("logo.png", io.BytesIO(_png()), "image/png")},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
icon = DriverIcon.get(DriverIcon.driver == driver.id)
|
||||
assert icon.original_filename == "logo.png"
|
||||
assert (tmp_data_dir / "icons" / icon.sha256).exists()
|
||||
|
||||
def test_off_size_image_is_normalized(self, client, tmp_data_dir):
|
||||
from imptune.db.models import DriverIcon
|
||||
|
||||
driver = _create_driver()
|
||||
client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("wide.png", io.BytesIO(_png(600, 120)), "image/png")},
|
||||
)
|
||||
icon = DriverIcon.get(DriverIcon.driver == driver.id)
|
||||
with Image.open(tmp_data_dir / "icons" / icon.sha256) as img:
|
||||
assert img.size == (256, 256)
|
||||
|
||||
def test_second_upload_replaces_the_first(self, client, tmp_data_dir):
|
||||
from imptune.db.models import DriverIcon
|
||||
|
||||
driver = _create_driver()
|
||||
client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("a.png", io.BytesIO(_png(color="red")), "image/png")},
|
||||
)
|
||||
client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("b.png", io.BytesIO(_png(color="green")), "image/png")},
|
||||
)
|
||||
icons = list(DriverIcon.select().where(DriverIcon.driver == driver.id))
|
||||
assert len(icons) == 1
|
||||
assert icons[0].original_filename == "b.png"
|
||||
|
||||
def test_response_carries_an_oob_row_refresh(self, client, tmp_data_dir):
|
||||
"""The dialog stays open, so the table thumbnail is swapped out of band."""
|
||||
driver = _create_driver()
|
||||
response = client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("logo.png", io.BytesIO(_png()), "image/png")},
|
||||
)
|
||||
assert f'id="driver-thumb-{driver.id}"' in response.text
|
||||
assert 'hx-swap-oob="true"' in response.text
|
||||
|
||||
def test_rejects_an_undecodable_file(self, client, tmp_data_dir):
|
||||
driver = _create_driver()
|
||||
response = client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("x.png", io.BytesIO(b"nope"), "image/png")},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_rejects_an_oversized_file(self, client, tmp_data_dir):
|
||||
driver = _create_driver()
|
||||
oversized = _png() + b"\x00" * (750 * 1024)
|
||||
response = client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("big.png", io.BytesIO(oversized), "image/png")},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "750" in response.text
|
||||
|
||||
def test_404_for_unknown_driver(self, client, tmp_data_dir):
|
||||
response = client.post(
|
||||
"/drivers/99999/icon",
|
||||
files={"file": ("logo.png", io.BytesIO(_png()), "image/png")},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestDriverIconFromWeb:
|
||||
def test_downloads_and_stores_the_picked_image(self, client, tmp_data_dir, monkeypatch):
|
||||
from imptune.db.models import DriverIcon
|
||||
from imptune.services import websearch
|
||||
|
||||
driver = _create_driver()
|
||||
monkeypatch.setattr(websearch, "fetch_image", lambda url: _png(400, 400))
|
||||
|
||||
response = client.post(
|
||||
f"/drivers/{driver.id}/icon/from-web",
|
||||
data={"url": "https://example.com/pictures/c300i.png"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
icon = DriverIcon.get(DriverIcon.driver == driver.id)
|
||||
assert icon.original_filename == "c300i.png"
|
||||
with Image.open(tmp_data_dir / "icons" / icon.sha256) as img:
|
||||
assert img.size == (256, 256)
|
||||
|
||||
def test_reports_a_refused_url(self, client, tmp_data_dir, monkeypatch):
|
||||
from imptune.services import websearch
|
||||
|
||||
driver = _create_driver()
|
||||
|
||||
def refuse(url):
|
||||
raise websearch.WebSearchError("10.0.0.5 resolves to a private address — refused.")
|
||||
|
||||
monkeypatch.setattr(websearch, "fetch_image", refuse)
|
||||
response = client.post(
|
||||
f"/drivers/{driver.id}/icon/from-web", data={"url": "http://10.0.0.5/x.png"}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "private address" in response.text
|
||||
|
||||
def test_reports_a_page_that_is_not_an_image(self, client, tmp_data_dir, monkeypatch):
|
||||
from imptune.services import websearch
|
||||
|
||||
driver = _create_driver()
|
||||
monkeypatch.setattr(websearch, "fetch_image", lambda url: b"<html>404</html>")
|
||||
response = client.post(
|
||||
f"/drivers/{driver.id}/icon/from-web", data={"url": "https://example.com/x"}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "readable image" in response.text
|
||||
|
||||
|
||||
class TestDriverIconServe:
|
||||
def test_serves_the_png(self, client, tmp_data_dir):
|
||||
driver = _create_driver()
|
||||
client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("logo.png", io.BytesIO(_png()), "image/png")},
|
||||
)
|
||||
response = client.get(f"/drivers/{driver.id}/icon")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
|
||||
def test_visible_to_another_owner(self, client, tmp_data_dir):
|
||||
"""The driver library is shared, so its icons are not owner-scoped."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from imptune.main import app
|
||||
|
||||
driver = _create_driver()
|
||||
client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("logo.png", io.BytesIO(_png()), "image/png")},
|
||||
)
|
||||
with TestClient(app) as other:
|
||||
assert other.get(f"/drivers/{driver.id}/icon").status_code == 200
|
||||
|
||||
def test_404_without_an_icon(self, client, tmp_data_dir):
|
||||
driver = _create_driver()
|
||||
assert client.get(f"/drivers/{driver.id}/icon").status_code == 404
|
||||
|
||||
def test_404_when_the_file_vanished(self, client, tmp_data_dir):
|
||||
from imptune.db.models import DriverIcon
|
||||
|
||||
driver = _create_driver()
|
||||
client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("logo.png", io.BytesIO(_png()), "image/png")},
|
||||
)
|
||||
icon = DriverIcon.get(DriverIcon.driver == driver.id)
|
||||
(tmp_data_dir / "icons" / icon.sha256).unlink()
|
||||
assert client.get(f"/drivers/{driver.id}/icon").status_code == 404
|
||||
|
||||
|
||||
def test_library_row_shows_the_thumbnail(client, tmp_data_dir):
|
||||
driver = _create_driver()
|
||||
client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("logo.png", io.BytesIO(_png()), "image/png")},
|
||||
)
|
||||
page = client.get("/drivers")
|
||||
assert f'src="/drivers/{driver.id}/icon"' in page.text
|
||||
|
||||
|
||||
def test_printer_and_driver_icons_share_one_stored_file(client, owner, tmp_data_dir):
|
||||
"""Content-addressed storage: identical bytes land on the same path."""
|
||||
from imptune.db.models import DriverIcon, Icon, Printer
|
||||
|
||||
driver = _create_driver()
|
||||
printer = Printer.create(
|
||||
name="P", ip_address="10.0.0.1", port_name="IP_10.0.0.1", owner=owner
|
||||
)
|
||||
png = _png(300, 300)
|
||||
client.post(
|
||||
f"/drivers/{driver.id}/icon",
|
||||
files={"file": ("a.png", io.BytesIO(png), "image/png")},
|
||||
)
|
||||
client.post(
|
||||
f"/printers/{printer.id}/icon",
|
||||
files={"file": ("b.png", io.BytesIO(png), "image/png")},
|
||||
)
|
||||
assert (
|
||||
DriverIcon.get(DriverIcon.driver == driver.id).sha256
|
||||
== Icon.get(Icon.printer == printer.id).sha256
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Driver rename — PATCH /drivers/{id}. Global/shared, like the Driver row."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _create_driver(**overrides):
|
||||
from imptune.db.models import Driver
|
||||
|
||||
fields = {
|
||||
"sha256": "a" * 64,
|
||||
"original_filename": "hp_m404_x64.zip",
|
||||
"size_bytes": 2048,
|
||||
"driver_desc": json.dumps(["HP LaserJet M404 PCL-6"]),
|
||||
"inf_filename": "hpm404.inf",
|
||||
"architecture": "amd64",
|
||||
}
|
||||
fields.update(overrides)
|
||||
return Driver.create(**fields)
|
||||
|
||||
|
||||
class TestDriverRename:
|
||||
def test_sets_display_name(self, client, tmp_data_dir):
|
||||
from imptune.db.models import Driver
|
||||
|
||||
driver = _create_driver()
|
||||
response = client.patch(
|
||||
f"/drivers/{driver.id}", data={"display_name": "HP UPD PCL6 (étage 2)"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert Driver.get_by_id(driver.id).display_name == "HP UPD PCL6 (étage 2)"
|
||||
|
||||
def test_renamed_driver_shows_both_names_in_the_list(self, client, tmp_data_dir):
|
||||
driver = _create_driver()
|
||||
response = client.patch(
|
||||
f"/drivers/{driver.id}", data={"display_name": "Accounting MFP"}
|
||||
)
|
||||
assert "Accounting MFP" in response.text
|
||||
# The ZIP name stays visible as the sub-line — it is what is on disk.
|
||||
assert "hp_m404_x64.zip" in response.text
|
||||
|
||||
def test_empty_value_clears_the_rename(self, client, tmp_data_dir):
|
||||
from imptune.db.models import Driver
|
||||
|
||||
driver = _create_driver(display_name="Old label")
|
||||
client.patch(f"/drivers/{driver.id}", data={"display_name": " "})
|
||||
assert Driver.get_by_id(driver.id).display_name is None
|
||||
|
||||
def test_rename_is_visible_to_another_owner(self, client, tmp_data_dir):
|
||||
"""Drivers are shared, so a rename is not scoped to the renamer."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from imptune.main import app
|
||||
|
||||
driver = _create_driver()
|
||||
client.patch(f"/drivers/{driver.id}", data={"display_name": "Shared label"})
|
||||
|
||||
with TestClient(app) as other: # fresh cookie jar → a different Owner
|
||||
page = other.get("/drivers")
|
||||
assert "Shared label" in page.text
|
||||
|
||||
def test_404_for_unknown_driver(self, client, tmp_data_dir):
|
||||
response = client.patch("/drivers/99999", data={"display_name": "x"})
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_rejects_an_overlong_name(self, client, tmp_data_dir):
|
||||
from imptune.db.models import Driver
|
||||
|
||||
driver = _create_driver()
|
||||
response = client.patch(
|
||||
f"/drivers/{driver.id}", data={"display_name": "x" * 121}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert Driver.get_by_id(driver.id).display_name is None
|
||||
|
||||
def test_missing_field_clears_rather_than_erroring(self, client, tmp_data_dir):
|
||||
from imptune.db.models import Driver
|
||||
|
||||
driver = _create_driver(display_name="Old label")
|
||||
response = client.patch(f"/drivers/{driver.id}", data={})
|
||||
assert response.status_code == 200
|
||||
assert Driver.get_by_id(driver.id).display_name is None
|
||||
@@ -66,16 +66,35 @@ class TestIconUpload:
|
||||
icon_file = Path(tmp_data_dir) / "icons" / sha256
|
||||
assert icon_file.exists()
|
||||
|
||||
def test_reject_non_png(self, client, owner, tmp_data_dir):
|
||||
"""POST with a JPEG file returns 422 with PNG format error."""
|
||||
def test_jpeg_is_converted(self, client, owner, tmp_data_dir):
|
||||
"""A JPEG is accepted and re-encoded as a 256x256 PNG, not rejected."""
|
||||
from pathlib import Path
|
||||
|
||||
from imptune.db.models import Icon
|
||||
|
||||
printer = _create_printer(owner)
|
||||
jpeg_data = _make_jpeg(256, 256)
|
||||
response = client.post(
|
||||
f"/printers/{printer.id}/icon",
|
||||
files={"file": ("icon.jpg", io.BytesIO(jpeg_data), "image/jpeg")},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
icon = Icon.get(Icon.printer == printer.id)
|
||||
stored = Path(tmp_data_dir) / "icons" / icon.sha256
|
||||
with Image.open(stored) as img:
|
||||
assert img.format == "PNG"
|
||||
assert img.size == (256, 256)
|
||||
|
||||
def test_reject_undecodable_file(self, client, owner, tmp_data_dir):
|
||||
"""A file Pillow cannot open is still refused."""
|
||||
printer = _create_printer(owner)
|
||||
response = client.post(
|
||||
f"/printers/{printer.id}/icon",
|
||||
files={"file": ("icon.png", io.BytesIO(b"not an image at all"), "image/png")},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "PNG" in response.text
|
||||
assert "readable image" in response.text
|
||||
|
||||
def test_reject_oversized(self, client, owner, tmp_data_dir):
|
||||
"""POST with PNG > 750KB returns 422 with 750 KB error."""
|
||||
@@ -90,16 +109,24 @@ class TestIconUpload:
|
||||
assert response.status_code == 422
|
||||
assert "750" in response.text
|
||||
|
||||
def test_reject_wrong_dimensions(self, client, owner, tmp_data_dir):
|
||||
"""POST with 128x128 PNG returns 422 with 256x256 error."""
|
||||
def test_wrong_dimensions_are_resized(self, client, owner, tmp_data_dir):
|
||||
"""An off-size PNG is letterboxed into 256x256 instead of rejected."""
|
||||
from pathlib import Path
|
||||
|
||||
from imptune.db.models import Icon
|
||||
|
||||
printer = _create_printer(owner)
|
||||
png_data = _make_png(128, 128)
|
||||
png_data = _make_png(128, 400)
|
||||
response = client.post(
|
||||
f"/printers/{printer.id}/icon",
|
||||
files={"file": ("small.png", io.BytesIO(png_data), "image/png")},
|
||||
files={"file": ("tall.png", io.BytesIO(png_data), "image/png")},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "256x256" in response.text
|
||||
assert response.status_code == 200
|
||||
|
||||
icon = Icon.get(Icon.printer == printer.id)
|
||||
stored = Path(tmp_data_dir) / "icons" / icon.sha256
|
||||
with Image.open(stored) as img:
|
||||
assert img.size == (256, 256)
|
||||
|
||||
def test_replace_existing_icon(self, client, owner, tmp_data_dir):
|
||||
"""Second upload for same printer replaces the Icon record."""
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Icon normalization — anything decodable becomes a 256x256 PNG."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from imptune.services.image_utils import ICON_SIZE, ImageError, normalize_icon
|
||||
|
||||
|
||||
def _png(width: int, height: int, mode: str = "RGBA") -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new(mode, (width, height), color="red").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _jpeg(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (width, height), color="blue").save(buf, format="JPEG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_exact_png_passes_through_byte_identical():
|
||||
"""The icon store is content-addressed — re-encoding would move the file."""
|
||||
data = _png(*ICON_SIZE)
|
||||
assert normalize_icon(data) is data
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[_png(64, 64), _png(1024, 1024), _png(1024, 128), _jpeg(300, 200)],
|
||||
ids=["small", "large", "wide", "jpeg"],
|
||||
)
|
||||
def test_everything_else_becomes_a_256_png(source):
|
||||
out = normalize_icon(source)
|
||||
with Image.open(io.BytesIO(out)) as img:
|
||||
assert img.format == "PNG"
|
||||
assert img.size == ICON_SIZE
|
||||
|
||||
|
||||
def test_aspect_ratio_is_kept_not_stretched():
|
||||
"""A 400x100 source keeps its 4:1 shape, letterboxed in a square canvas.
|
||||
|
||||
Checked through the alpha channel: the padding stays fully transparent, so
|
||||
the opaque band is 64px tall in a 256px canvas.
|
||||
"""
|
||||
out = normalize_icon(_png(400, 100))
|
||||
with Image.open(io.BytesIO(out)) as img:
|
||||
alpha = img.convert("RGBA").split()[3]
|
||||
opaque_rows = [
|
||||
y for y in range(256) if any(alpha.getpixel((x, y)) for x in range(256))
|
||||
]
|
||||
assert len(opaque_rows) == 64
|
||||
# ...and it is centered, not flush to the top.
|
||||
assert opaque_rows[0] == 96
|
||||
|
||||
|
||||
def test_undecodable_bytes_raise():
|
||||
with pytest.raises(ImageError):
|
||||
normalize_icon(b"this is not an image")
|
||||
|
||||
|
||||
def test_empty_bytes_raise():
|
||||
with pytest.raises(ImageError):
|
||||
normalize_icon(b"")
|
||||
@@ -0,0 +1,267 @@
|
||||
"""/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
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Web lookup service — scrape parsing, query building, and the SSRF guard.
|
||||
|
||||
Nothing here touches the network: `_get` is monkeypatched, and the one test that
|
||||
needs a "public" hostname fakes the DNS answer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from imptune.services import websearch
|
||||
|
||||
# Captured before any monkeypatching so `TestFetchGuard` can put the real guard
|
||||
# back after the module-wide autouse fixture has stubbed it out.
|
||||
_REAL_ASSERT_FETCHABLE = websearch.assert_fetchable
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _allow_any_host(monkeypatch):
|
||||
"""Neutralize the SSRF guard for parsing tests — it has its own tests below."""
|
||||
monkeypatch.setattr(websearch, "assert_fetchable", lambda url: None)
|
||||
|
||||
|
||||
def _canned(monkeypatch, mapping: dict[str, bytes]):
|
||||
"""Serve `_get` from a {url-substring: body} table."""
|
||||
|
||||
def fake_get(url, *, referer=None, max_bytes=0, extra_headers=None):
|
||||
for fragment, body in mapping.items():
|
||||
if fragment in url:
|
||||
return body
|
||||
raise websearch.WebSearchError(f"unexpected url {url}")
|
||||
|
||||
monkeypatch.setattr(websearch, "_get", fake_get)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Image search
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
IMAGE_PAYLOAD = json.dumps(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"image": "https://cdn.example.com/m404.png",
|
||||
"thumbnail": "https://tn.example.com/m404.png",
|
||||
"title": "HP LaserJet M404",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"url": "https://example.com/page",
|
||||
},
|
||||
{
|
||||
"image": "data:image/png;base64,AAAA",
|
||||
"thumbnail": "https://tn.example.com/skip.png",
|
||||
"title": "inline",
|
||||
"width": 1,
|
||||
"height": 1,
|
||||
},
|
||||
{
|
||||
"image": "https://cdn.example.com/second.jpg",
|
||||
"thumbnail": "",
|
||||
"title": "",
|
||||
"width": None,
|
||||
"height": None,
|
||||
},
|
||||
]
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def test_search_images_parses_results(monkeypatch):
|
||||
_canned(
|
||||
monkeypatch,
|
||||
{"duckduckgo.com/?q=": b'... vqd="4-12345678" ...', "i.js": IMAGE_PAYLOAD},
|
||||
)
|
||||
hits = websearch.search_images("hp laserjet m404")
|
||||
|
||||
# The data: URI is dropped — only fetchable http(s) images survive.
|
||||
assert [h.url for h in hits] == [
|
||||
"https://cdn.example.com/m404.png",
|
||||
"https://cdn.example.com/second.jpg",
|
||||
]
|
||||
assert hits[0].width == 800
|
||||
assert hits[0].source == "cdn.example.com"
|
||||
# A result with no thumbnail falls back to the full image.
|
||||
assert hits[1].thumbnail == "https://cdn.example.com/second.jpg"
|
||||
assert hits[1].width == 0
|
||||
|
||||
|
||||
def test_search_images_honours_limit(monkeypatch):
|
||||
_canned(
|
||||
monkeypatch,
|
||||
{"duckduckgo.com/?q=": b'vqd="4-12345678"', "i.js": IMAGE_PAYLOAD},
|
||||
)
|
||||
assert len(websearch.search_images("x", limit=1)) == 1
|
||||
|
||||
|
||||
def test_search_images_returns_empty_when_token_is_missing(monkeypatch):
|
||||
"""A markup change upstream must degrade to "no results", not a 500."""
|
||||
_canned(monkeypatch, {"duckduckgo.com/?q=": b"<html>redesigned</html>"})
|
||||
assert websearch.search_images("anything") == []
|
||||
|
||||
|
||||
def test_search_images_returns_empty_on_bad_json(monkeypatch):
|
||||
_canned(
|
||||
monkeypatch,
|
||||
{"duckduckgo.com/?q=": b'vqd="4-12345678"', "i.js": b"<!doctype html>"},
|
||||
)
|
||||
assert websearch.search_images("anything") == []
|
||||
|
||||
|
||||
def test_search_images_skips_the_request_for_a_blank_query(monkeypatch):
|
||||
def explode(*args, **kwargs):
|
||||
raise AssertionError("should not hit the network")
|
||||
|
||||
monkeypatch.setattr(websearch, "_get", explode)
|
||||
assert websearch.search_images(" ") == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Page search
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
HTML_RESULTS = b"""
|
||||
<div class="result">
|
||||
<a rel="nofollow" class="result__a"
|
||||
href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fsupport.hp.com%2Fupd&rut=abc">
|
||||
HP <b>Universal Print Driver</b>
|
||||
</a>
|
||||
<a class="result__snippet">Download the <b>UPD</b> for Windows.</a>
|
||||
</div>
|
||||
<div class="result">
|
||||
<a rel="nofollow" class="result__a" href="https://direct.example.com/drivers">Direct link</a>
|
||||
<a class="result__snippet">Second snippet</a>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def test_search_pages_unwraps_the_redirect_and_strips_markup(monkeypatch):
|
||||
_canned(monkeypatch, {"html.duckduckgo.com": HTML_RESULTS})
|
||||
hits = websearch.search_pages("hp upd")
|
||||
|
||||
assert hits[0].url == "https://support.hp.com/upd"
|
||||
assert hits[0].title == "HP Universal Print Driver"
|
||||
assert hits[0].snippet == "Download the UPD for Windows."
|
||||
assert hits[0].host == "support.hp.com"
|
||||
assert hits[1].url == "https://direct.example.com/drivers"
|
||||
|
||||
|
||||
def test_search_pages_returns_empty_on_transport_failure(monkeypatch):
|
||||
def fail(*args, **kwargs):
|
||||
raise websearch.WebSearchError("boom")
|
||||
|
||||
monkeypatch.setattr(websearch, "_get", fail)
|
||||
assert websearch.search_pages("hp upd") == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SSRF guard
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestFetchGuard:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _real_guard(self, monkeypatch):
|
||||
monkeypatch.setattr(websearch, "assert_fetchable", _REAL_ASSERT_FETCHABLE)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"file:///etc/passwd",
|
||||
"ftp://example.com/x",
|
||||
"gopher://example.com",
|
||||
],
|
||||
)
|
||||
def test_non_http_schemes_refused(self, url):
|
||||
with pytest.raises(websearch.WebSearchError):
|
||||
websearch.assert_fetchable(url)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://127.0.0.1/admin",
|
||||
"http://localhost:8000/",
|
||||
"http://10.0.0.5/printer",
|
||||
"http://192.168.1.1/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[::1]/",
|
||||
],
|
||||
)
|
||||
def test_private_targets_refused(self, url):
|
||||
"""ImpTune sits on the printer LAN — this is the whole point of the guard."""
|
||||
with pytest.raises(websearch.WebSearchError, match="private|resolve"):
|
||||
websearch.assert_fetchable(url)
|
||||
|
||||
def test_public_target_allowed(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
websearch.socket,
|
||||
"getaddrinfo",
|
||||
lambda host, port: [(2, 1, 6, "", ("93.184.216.34", 0))],
|
||||
)
|
||||
websearch.assert_fetchable("https://example.com/logo.png")
|
||||
|
||||
def test_a_host_resolving_to_both_is_refused(self, monkeypatch):
|
||||
"""One private answer in the set is enough to refuse the whole host."""
|
||||
monkeypatch.setattr(
|
||||
websearch.socket,
|
||||
"getaddrinfo",
|
||||
lambda host, port: [
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
(2, 1, 6, "", ("127.0.0.1", 0)),
|
||||
],
|
||||
)
|
||||
with pytest.raises(websearch.WebSearchError):
|
||||
websearch.assert_fetchable("https://rebind.example.com/x")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Query builders and the kill switch
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("typed", "expected"),
|
||||
[
|
||||
("HP LaserJet M404dn", "HP Universal Print Driver PCL6 download"),
|
||||
("bizhub C300i (Konica Minolta)", "Konica Minolta Universal PCL Print Driver download"),
|
||||
("Xerox VersaLink C405", "Xerox Global Print Driver download"),
|
||||
],
|
||||
)
|
||||
def test_generic_driver_query_uses_the_vendor_product_name(typed, expected):
|
||||
assert websearch.generic_driver_query(typed) == expected
|
||||
|
||||
|
||||
def test_generic_driver_query_falls_back_to_the_typed_model():
|
||||
assert (
|
||||
websearch.generic_driver_query("Acme 9000")
|
||||
== "Acme 9000 universal print driver download"
|
||||
)
|
||||
|
||||
|
||||
def test_detect_brand_prefers_the_longest_match():
|
||||
""""konica" and "konica minolta" both match — the specific one wins."""
|
||||
assert websearch.detect_brand("Konica Minolta bizhub") == "konica minolta"
|
||||
assert websearch.detect_brand("Acme 9000") is None
|
||||
|
||||
|
||||
def test_image_query_appends_printer():
|
||||
assert websearch.image_query("HP M404") == "HP M404 printer"
|
||||
assert websearch.image_query(" ") == ""
|
||||
|
||||
|
||||
def test_disabled_flag_blocks_every_lookup(monkeypatch):
|
||||
monkeypatch.setattr(websearch.cfg, "WEB_SEARCH", False)
|
||||
assert websearch.enabled() is False
|
||||
for call in (
|
||||
lambda: websearch.search_images("x"),
|
||||
lambda: websearch.search_pages("x"),
|
||||
lambda: websearch.fetch_image("https://example.com/x.png"),
|
||||
):
|
||||
with pytest.raises(websearch.WebSearchError, match="disabled"):
|
||||
call()
|
||||
Reference in New Issue
Block a user