Compare commits

...

2 Commits

Author SHA1 Message Date
c158d920d2 chore(android-terminal): dev cleartext + readable HTTP error messages
Some checks failed
CI / ruff (push) Successful in 16s
CI / pytest (push) Failing after 2h32m6s
CI / validate (push) Successful in 31s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Two small dev-quality fixes shaken out by manual testing:

- Add a debug-only network_security_config.xml that whitelists 10.0.2.2,
  localhost and 127.0.0.1 for cleartext HTTP. Without this, the dev
  emulator can't reach the Python dev server because targetSdk 35
  forbids cleartext HTTP by default. Lives under app/src/debug/ so it
  ships only in debug APKs — release builds keep the platform default
  (no cleartext at all).

- TerminalViewModel.runAction now extracts the JSON {message: ...} field
  from HttpException response bodies instead of just showing "HTTP 400".
  Cashiers (and developers) now see "Staff PIN is required for this
  operation" / "Daily stamp limit of N reached" inline in the failed
  action sheet, surfacing the same business-error text the server
  already returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:17:44 +02:00
c1bb225228 fix(loyalty): paired-device JWT bypasses program.require_staff_pin
When the request principal is a paired POS terminal device
(current_user.terminal_device_id is not None), the staff PIN is
considered already-verified — the cashier bcrypt-verified locally on
the tablet's lock screen against the cached hashes from
/pins/for-device. Web-terminal user JWTs still require the per-action
PIN as before; the strict fraud-prevention path is unchanged.

Threat-model note: the device JWT is itself proof of authentication.
The merchant owner paired the device, the cashier verified locally,
and the JWT is revocable from /merchants/loyalty/devices. The 2-min
idle auto-lock + acting_terminal_device_id audit column give us the
attribution we'd otherwise get from a per-action PIN.

Applied to: stamp_service.add_stamp / redeem_stamps / void_stamps;
points_service.earn_points / redeem_points / void_points. adjust_points
was already permissive on missing PIN. New tests in TestDevicePinBypass
lock both the bypass behavior and the still-strict web-terminal path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:16:33 +02:00
6 changed files with 120 additions and 13 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Debug-only manifest overlay. Merged onto the main manifest by AGP for
debug builds; release builds skip this and keep the platform default
(no cleartext HTTP). -->
<application
android:networkSecurityConfig="@xml/network_security_config"
tools:replace="android:networkSecurityConfig" />
</manifest>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only: allow cleartext HTTP to the dev server. The release build
(in app/src/main/AndroidManifest.xml) does NOT include this resource and
keeps the platform default which forbids cleartext entirely.
10.0.2.2 → host machine from a standard Android emulator
localhost → loopback within the emulator itself (rarely useful)
Add your LAN IP here too if you test against a hardware tablet on wifi.
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@@ -25,6 +25,7 @@ import lu.rewardflow.terminal.data.model.TransactionItem
import lu.rewardflow.terminal.data.network.NetworkMonitor
import lu.rewardflow.terminal.data.repository.CategoryRepository
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
import retrofit2.HttpException
import javax.inject.Inject
/**
@@ -294,13 +295,34 @@ class TerminalViewModel @Inject constructor(
_state.value = _state.value.copy(
actionInProgress = false,
actionResult = ActionResult.Failure(
result.exceptionOrNull()?.message ?: "Operation failed"
readableErrorMessage(result.exceptionOrNull())
),
)
}
}
}
/** Extract a human-readable message from a Retrofit/HttpException.
*
* Retrofit's default `message` is the HTTP status text ("Bad Request"),
* which hides the JSON ``{message, error_code, details}`` body the
* backend returns. Read the body and surface its message instead so
* cashiers see "Staff PIN is required" or "Card not found" rather
* than the generic 400. */
private fun readableErrorMessage(t: Throwable?): String {
if (t is HttpException) {
val body = runCatching { t.response()?.errorBody()?.string() }.getOrNull()
if (!body.isNullOrBlank()) {
// Cheap JSON peek — avoid pulling in a full adapter for one field.
val match = "\"message\"\\s*:\\s*\"([^\"]+)\"".toRegex().find(body)
if (match != null) return match.groupValues[1]
return body.take(200)
}
return "HTTP ${t.code()}"
}
return t?.message ?: "Operation failed"
}
fun refreshCurrentCustomer() {
val current = _state.value.customer ?: return
viewModelScope.launch {