Promote the 2026-08-30 review security fixes and supplies ordering to main
This commit is contained in:
@@ -79,3 +79,73 @@ def test_a_package_with_no_recorded_digest_is_refused_not_delivered_unverified(a
|
||||
|
||||
assert descriptor is None
|
||||
assert 'SHA-256' in error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The digest is pinned when an operator approves the push, not when the agent
|
||||
# claims it. Between those two moments the bytes behind a descriptor can be
|
||||
# replaced by anyone who can edit the catalog, and the re-resolved descriptor
|
||||
# would faithfully describe the NEW bytes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_an_unpinned_command_still_claims(app):
|
||||
"""Actions that deliver no content, and rows queued before the pin existed."""
|
||||
from plugins.endpoints.api.routes import _pin_holds
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
with app.app_context():
|
||||
command = EndpointCommand(assetid=1, action='mtconnect_start',
|
||||
pinnedsha256=None)
|
||||
assert _pin_holds(command, None) is True
|
||||
assert _pin_holds(command, {'sha256': 'b' * 64}) is True
|
||||
|
||||
|
||||
def test_a_matching_digest_claims(app):
|
||||
from plugins.endpoints.api.routes import _pin_holds
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
with app.app_context():
|
||||
command = EndpointCommand(assetid=1, action='run_script',
|
||||
pinnedsha256='c' * 64)
|
||||
assert _pin_holds(command, {'sha256': 'c' * 64}) is True
|
||||
|
||||
|
||||
def test_a_swapped_digest_does_not_claim(app):
|
||||
"""The finding this exists for: approve one script, ship another.
|
||||
|
||||
An operator queues run_script against a reviewed body. Before the agent's
|
||||
long poll returns, somebody with applications.edit replaces the body and
|
||||
the digest is restamped. Without the pin the claim re-resolves, hands the
|
||||
agent the attacker's digest, and the agent verifies it correctly.
|
||||
"""
|
||||
from plugins.endpoints.api.routes import _pin_holds
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
with app.app_context():
|
||||
command = EndpointCommand(assetid=1, action='run_script',
|
||||
pinnedsha256='c' * 64)
|
||||
assert _pin_holds(command, {'sha256': 'd' * 64}) is False
|
||||
|
||||
|
||||
def test_a_vanished_descriptor_is_not_treated_as_a_swap(app):
|
||||
"""A deleted script already sends the command through with no descriptor so
|
||||
the agent fails closed; that path must not be reported as tampering."""
|
||||
from plugins.endpoints.api.routes import _pin_holds
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
with app.app_context():
|
||||
command = EndpointCommand(assetid=1, action='run_script',
|
||||
pinnedsha256='c' * 64)
|
||||
assert _pin_holds(command, None) is True
|
||||
|
||||
|
||||
def test_enqueue_records_the_digest_it_approved(app, hosted_app):
|
||||
"""The resolved descriptor used to be discarded at enqueue (`_descriptor`)."""
|
||||
from plugins.endpoints.api.routes import _resolve_update_app
|
||||
|
||||
appid, appversionid = hosted_app
|
||||
with app.app_context():
|
||||
descriptor, error = _resolve_update_app(str(appversionid))
|
||||
assert error is None
|
||||
# This is the value the enqueue path now writes to pinnedsha256.
|
||||
assert descriptor['sha256'] == 'a' * 64
|
||||
|
||||
@@ -107,21 +107,47 @@ def test_payload_download_is_rate_limited(client, app, auth_headers):
|
||||
|
||||
|
||||
def test_rate_limit_is_per_ip(client, app, auth_headers):
|
||||
"""A second caller (different X-Forwarded-For) has its own budget: one IP
|
||||
being throttled must not throttle everyone."""
|
||||
"""A second caller has its own budget: one IP being throttled must not
|
||||
throttle everyone.
|
||||
|
||||
Callers are distinguished by REMOTE_ADDR, the peer that actually connected,
|
||||
which is what ProxyFix fills from X-Forwarded-For when a proxy is really in
|
||||
front of this instance.
|
||||
"""
|
||||
sha = _store(app, b'per-ip blob')
|
||||
key = _fetch_key(client, auth_headers)
|
||||
hot = {**key, 'X-Forwarded-For': '203.0.113.202'}
|
||||
cool = {**key, 'X-Forwarded-For': '203.0.113.203'}
|
||||
hot = {'REMOTE_ADDR': '203.0.113.202'}
|
||||
cool = {'REMOTE_ADDR': '203.0.113.203'}
|
||||
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
|
||||
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=hot).status_code == 200
|
||||
headers=key, environ_base=hot).status_code == 200
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=hot).status_code == 429
|
||||
headers=key, environ_base=hot).status_code == 429
|
||||
# Fresh IP still gets its first request through.
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=cool).status_code == 200
|
||||
headers=key, environ_base=cool).status_code == 200
|
||||
|
||||
|
||||
def test_rate_limit_cannot_be_reset_by_forging_a_forwarded_header(
|
||||
client, app, auth_headers):
|
||||
"""The budget follows the connection, not a header the caller writes.
|
||||
|
||||
The limiter used to key on the first X-Forwarded-For hop, so rotating that
|
||||
header per request bought an unbounded budget and the limit stopped being
|
||||
a limit.
|
||||
"""
|
||||
sha = _store(app, b'forged header blob')
|
||||
key = _fetch_key(client, auth_headers)
|
||||
caller = {'REMOTE_ADDR': '203.0.113.210'}
|
||||
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
|
||||
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
|
||||
assert client.get(f'/api/geenforce/payload/{sha}', headers=key,
|
||||
environ_base=caller).status_code == 200
|
||||
for hop in ('198.51.100.1', '198.51.100.2', '198.51.100.3'):
|
||||
spoofed = {**key, 'X-Forwarded-For': hop}
|
||||
assert client.get(f'/api/geenforce/payload/{sha}', headers=spoofed,
|
||||
environ_base=caller).status_code == 429
|
||||
|
||||
|
||||
def test_rate_limit_can_be_disabled(client, app, auth_headers):
|
||||
|
||||
101
tests/test_plugins/test_supplies_card_ordering.py
Normal file
101
tests/test_plugins/test_supplies_card_ordering.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""The supplies card must put the emptiest cartridge first.
|
||||
|
||||
It sorted on iscritical, then on PRINTER NAME. Critical is everything at or
|
||||
below 5%, so a cartridge at 5% and one at 0% tied on the first key and the
|
||||
alphabet decided the order.
|
||||
|
||||
That is bad ordering on its own, and worse than it looks: the dashboard queue
|
||||
takes only the first few rows per source, so a printer that is genuinely empty
|
||||
was truncated out of the card while a fuller one held its place. The card
|
||||
existed to answer "what do I walk out and change today" and was answering it
|
||||
with the alphabet.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _call(client, auth_headers):
|
||||
response = client.get('/api/printers/dashboard/supplies',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
return response.get_json()['data']
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache(app):
|
||||
from shopdb.extensions import cache
|
||||
cache.delete('printers_low_supplies')
|
||||
yield
|
||||
cache.delete('printers_low_supplies')
|
||||
|
||||
|
||||
def _printer(printerid, name, remaining, status='critical'):
|
||||
return {
|
||||
'printerid': printerid,
|
||||
'printername': name,
|
||||
'assetnumber': 'A{}'.format(printerid),
|
||||
'location': 'Shop floor',
|
||||
'mapx': None, 'mapy': None, 'levelid': None,
|
||||
'supplies': [{
|
||||
'name': 'Black Toner', 'level': remaining, 'remaining': remaining,
|
||||
'status': status, 'iswaste': False, 'isdrum': False,
|
||||
'partnumbers': [], 'lastseen': None, 'color': 'black',
|
||||
'supplytype': 'toner',
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def _patched(rows):
|
||||
return patch('plugins.printers.api.asset_routes._get_low_supplies_data',
|
||||
return_value={'printers': rows,
|
||||
'summary': {'total_checked': len(rows),
|
||||
'low': 0, 'critical': len(rows)}})
|
||||
|
||||
|
||||
def test_the_empty_cartridge_comes_before_the_nearly_empty_one(client,
|
||||
auth_headers):
|
||||
"""'Alpha' at 5% used to precede 'Zulu' at 0% purely on the name."""
|
||||
with _patched([_printer(1, 'Alpha', 5), _printer(2, 'Zulu', 0)]):
|
||||
data = _call(client, auth_headers)
|
||||
|
||||
order = [row['printername'] for row in data['rows']]
|
||||
assert order == ['Zulu', 'Alpha']
|
||||
|
||||
|
||||
def test_rows_are_ordered_by_how_empty_they_are(client, auth_headers):
|
||||
with _patched([_printer(1, 'Alpha', 4),
|
||||
_printer(2, 'Bravo', 0),
|
||||
_printer(3, 'Charlie', 2),
|
||||
_printer(4, 'Delta', 5)]):
|
||||
data = _call(client, auth_headers)
|
||||
|
||||
remaining = [row['worstremaining'] for row in data['rows']]
|
||||
assert remaining == sorted(remaining)
|
||||
assert remaining == [0, 2, 4, 5]
|
||||
|
||||
|
||||
def test_a_zero_percent_printer_survives_the_queue_truncation(client,
|
||||
auth_headers):
|
||||
"""The dashboard queue keeps only the first few rows per source.
|
||||
|
||||
With five fuller printers whose names sort earlier, the empty one used to
|
||||
fall off the end of the card entirely.
|
||||
"""
|
||||
rows = [_printer(index, name, 5) for index, name in enumerate(
|
||||
['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'], start=1)]
|
||||
rows.append(_printer(6, 'Zulu', 0))
|
||||
|
||||
with _patched(rows):
|
||||
data = _call(client, auth_headers)
|
||||
|
||||
# QUEUEPERSOURCE is 5 on the frontend; first place is what matters here.
|
||||
assert data['rows'][0]['printername'] == 'Zulu'
|
||||
assert [r['printername'] for r in data['rows'][:5]].count('Zulu') == 1
|
||||
|
||||
|
||||
def test_the_name_still_breaks_a_tie(client, auth_headers):
|
||||
with _patched([_printer(1, 'Bravo', 3), _printer(2, 'Alpha', 3)]):
|
||||
data = _call(client, auth_headers)
|
||||
|
||||
assert [row['printername'] for row in data['rows']] == ['Alpha', 'Bravo']
|
||||
Reference in New Issue
Block a user