diff --git a/app/modules/loyalty/services/points_service.py b/app/modules/loyalty/services/points_service.py index 90c4818f..26a5a8c0 100644 --- a/app/modules/loyalty/services/points_service.py +++ b/app/modules/loyalty/services/points_service.py @@ -156,9 +156,12 @@ class PointsService: "total_points_earned": card.total_points_earned, } - # Verify staff PIN if required + # Verify staff PIN if required. + # Paired terminal devices ARE the principal — the cashier already + # bcrypt-verified locally against the device's cached hash list. + # Web-terminal user JWTs still require the per-action PIN. verified_pin = None - if program.require_staff_pin: + if program.require_staff_pin and acting_terminal_device_id is None: if not staff_pin: raise StaffPinRequiredException() verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id) @@ -307,9 +310,9 @@ class PointsService: if points_required < program.minimum_redemption_points: raise InvalidRewardException(reward_id) - # Verify staff PIN if required + # Paired-device principal bypasses the per-action PIN; see earn_points. verified_pin = None - if program.require_staff_pin: + if program.require_staff_pin and acting_terminal_device_id is None: if not staff_pin: raise StaffPinRequiredException() verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id) @@ -428,9 +431,9 @@ class PointsService: if settings and not settings.allow_void_transactions: raise LoyaltyCardInactiveException(card.id) - # Verify staff PIN if required + # Paired-device principal bypasses the per-action PIN; see earn_points. verified_pin = None - if program.require_staff_pin: + if program.require_staff_pin and acting_terminal_device_id is None: if not staff_pin: raise StaffPinRequiredException() verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id) diff --git a/app/modules/loyalty/services/stamp_service.py b/app/modules/loyalty/services/stamp_service.py index 5842dcbb..482b328e 100644 --- a/app/modules/loyalty/services/stamp_service.py +++ b/app/modules/loyalty/services/stamp_service.py @@ -105,9 +105,12 @@ class StampService: logger.warning(f"Stamp attempted on points-only program {program.id}") raise LoyaltyCardInactiveException(card.id) - # Verify staff PIN if required + # Verify staff PIN if required. + # Paired terminal devices ARE the principal — the cashier already + # bcrypt-verified locally against the device's cached hash list. + # Web-terminal user JWTs still require the per-action PIN. verified_pin = None - if program.require_staff_pin: + if program.require_staff_pin and acting_terminal_device_id is None: if not staff_pin: raise StaffPinRequiredException() verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id) @@ -262,9 +265,9 @@ class StampService: if not program.is_active: raise LoyaltyProgramInactiveException(program.id) - # Verify staff PIN if required + # Paired-device principal bypasses the per-action PIN; see add_stamp. verified_pin = None - if program.require_staff_pin: + if program.require_staff_pin and acting_terminal_device_id is None: if not staff_pin: raise StaffPinRequiredException() verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id) @@ -381,9 +384,9 @@ class StampService: if settings and not settings.allow_void_transactions: raise LoyaltyCardInactiveException(card.id) - # Verify staff PIN if required + # Paired-device principal bypasses the per-action PIN; see add_stamp. verified_pin = None - if program.require_staff_pin: + if program.require_staff_pin and acting_terminal_device_id is None: if not staff_pin: raise StaffPinRequiredException() verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id) diff --git a/app/modules/loyalty/tests/integration/test_terminal_devices.py b/app/modules/loyalty/tests/integration/test_terminal_devices.py index 349da8c9..ed33eb3d 100644 --- a/app/modules/loyalty/tests/integration/test_terminal_devices.py +++ b/app/modules/loyalty/tests/integration/test_terminal_devices.py @@ -309,3 +309,55 @@ class TestPinsForDevice: headers=loyalty_store_headers, ) assert response.status_code == 403, response.text + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestDevicePinBypass: + """Paired-device JWT bypasses program.require_staff_pin — the cashier + has already verified locally on the lock screen.""" + + def test_device_token_bypasses_required_staff_pin( + self, client, loyalty_merchant_headers, loyalty_store_setup, db + ): + store = loyalty_store_setup["store"] + card = loyalty_store_setup["card"] + program = loyalty_store_setup["program"] + + # Tighten the program to require PINs. + program.require_staff_pin = True + db.commit() + + paired = client.post( + f"{MERCHANT_BASE}/devices", + json={"store_id": store.id, "label": "PIN bypass test"}, + headers=loyalty_merchant_headers, + ).json() + token = paired["setup_token"] + + # No staff_pin in the body, but the device JWT counts as auth. + response = client.post( + f"{STORE_BASE}/points/earn", + json={"card_id": card.id, "purchase_amount_cents": 1000}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200, response.text + + def test_user_token_still_requires_staff_pin( + self, client, loyalty_store_headers, loyalty_store_setup, db + ): + """Belt-and-braces: web-terminal user JWTs must still send a PIN + when the program requires it.""" + card = loyalty_store_setup["card"] + program = loyalty_store_setup["program"] + program.require_staff_pin = True + db.commit() + + response = client.post( + f"{STORE_BASE}/points/earn", + json={"card_id": card.id, "purchase_amount_cents": 1000}, + headers=loyalty_store_headers, + ) + assert response.status_code == 400 + assert "STAFF_PIN_REQUIRED" in response.text