From ef885c66dccb97fb8a544b068e7dcbcc20cf40ba Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 14 May 2026 01:06:30 -0400 Subject: [PATCH] feat(fusion_clock): NFC tap endpoint debounce + 6 error-case tests Adds module-level 5s debounce (_is_debounced) with thread-safe dict + GC. Inserts debounce guard in nfc_tap immediately after uid validation. Adds TestTapEndpointErrors (6 tests): unknown_card, clock_disabled, no_location_configured, kiosk_disabled, invalid_uid, debounce. Adds setUp() to both tap test classes to clear _recent_taps between tests, preventing cross-test debounce bleed. 29/29 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_clock/controllers/clock_nfc_kiosk.py | 24 ++++++ fusion_clock/tests/test_clock_nfc_kiosk.py | 89 +++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/fusion_clock/controllers/clock_nfc_kiosk.py b/fusion_clock/controllers/clock_nfc_kiosk.py index 6c3f4da2..2f312a25 100644 --- a/fusion_clock/controllers/clock_nfc_kiosk.py +++ b/fusion_clock/controllers/clock_nfc_kiosk.py @@ -4,12 +4,33 @@ import logging import re +import time +import threading from odoo import fields, http from odoo.http import request _logger = logging.getLogger(__name__) _UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$') +_DEBOUNCE_WINDOW_SECONDS = 5.0 +_recent_taps = {} # {card_uid: monotonic_ts} +_recent_taps_lock = threading.Lock() + + +def _is_debounced(uid): + """Return True if this UID was tapped within the debounce window.""" + now = time.monotonic() + with _recent_taps_lock: + last = _recent_taps.get(uid, 0) + if now - last < _DEBOUNCE_WINDOW_SECONDS: + return True + _recent_taps[uid] = now + # Opportunistic GC: drop entries older than 60s + stale_keys = [k for k, t in _recent_taps.items() if now - t > 60] + for k in stale_keys: + _recent_taps.pop(k, None) + return False + class FusionClockNfcKiosk(http.Controller): """NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers.""" @@ -120,6 +141,9 @@ class FusionClockNfcKiosk(http.Controller): if not normalized: return {'error': 'invalid_uid'} + if _is_debounced(normalized): + return {'error': 'debounce'} + company = request.env.company location = company.x_fclk_nfc_kiosk_location_id if not location: diff --git a/fusion_clock/tests/test_clock_nfc_kiosk.py b/fusion_clock/tests/test_clock_nfc_kiosk.py index 9a1be663..0624d5f7 100644 --- a/fusion_clock/tests/test_clock_nfc_kiosk.py +++ b/fusion_clock/tests/test_clock_nfc_kiosk.py @@ -181,6 +181,12 @@ class TestTapEndpointHappyPath(HttpCase): 'x_fclk_nfc_card_uid': '04:A2:B5:62:C1:90', }) + def setUp(self): + super().setUp() + # Clear module-level debounce cache so tests don't inherit state from other classes + from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module + nfc_kiosk_module._recent_taps.clear() + def _tap(self, card_uid='04:A2:B5:62:C1:90', photo_b64=''): self.authenticate('nfc-kiosk-tap', 'kioskpass123') response = self.url_open( @@ -219,3 +225,86 @@ class TestTapEndpointHappyPath(HttpCase): ('employee_id', '=', self.alice.id), ], order='check_in desc', limit=1) self.assertTrue(attendance.check_out) + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestTapEndpointErrors(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ICP = cls.env['ir.config_parameter'].sudo() + cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True') + cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False') + cls.location = cls.env['fusion.clock.location'].create({ + 'name': 'Err Plant', + 'latitude': 43.65, + 'longitude': -79.38, + 'radius': 100, + }) + cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id + cls.kiosk_user = cls.env['res.users'].create({ + 'name': 'Err Kiosk User', + 'login': 'nfc-kiosk-err', + 'password': 'kioskpass123', + 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], + }) + cls.disabled_emp = cls.env['hr.employee'].create({ + 'name': 'Disabled E', + 'x_fclk_enable_clock': False, + 'x_fclk_nfc_card_uid': '04:A2:B5:62:DE:AD', + }) + cls.active_emp = cls.env['hr.employee'].create({ + 'name': 'Active E', + 'x_fclk_enable_clock': True, + 'x_fclk_nfc_card_uid': '04:A2:B5:62:AC:01', + }) + + def setUp(self): + super().setUp() + # Clear module-level debounce cache so tests don't bleed into each other + from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module + nfc_kiosk_module._recent_taps.clear() + # Reset ICP to known-good defaults before each test + self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True') + self.env.company.x_fclk_nfc_kiosk_location_id = self.location.id + + def _tap(self, card_uid): + self.authenticate('nfc-kiosk-err', 'kioskpass123') + response = self.url_open( + '/fusion_clock/kiosk/nfc/tap', + data=json.dumps({ + 'jsonrpc': '2.0', 'method': 'call', + 'params': {'card_uid': card_uid, 'photo_b64': ''}, + }), + headers={'Content-Type': 'application/json'}, + ) + return response.json().get('result', {}) + + def test_unknown_card(self): + result = self._tap('04:00:00:00:00:00') + self.assertEqual(result.get('error'), 'card_unknown') + + def test_disabled_employee(self): + result = self._tap('04:A2:B5:62:DE:AD') + self.assertEqual(result.get('error'), 'clock_disabled') + + def test_no_location_configured(self): + self.env.company.x_fclk_nfc_kiosk_location_id = False + result = self._tap('04:A2:B5:62:AC:01') + self.assertEqual(result.get('error'), 'no_location_configured') + + def test_kiosk_disabled(self): + self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False') + result = self._tap('04:A2:B5:62:AC:01') + self.assertEqual(result.get('error'), 'kiosk_disabled') + + def test_invalid_uid(self): + result = self._tap('not-a-uid') + self.assertEqual(result.get('error'), 'invalid_uid') + + def test_debounce_silent_second_tap(self): + first = self._tap('04:A2:B5:62:AC:01') + self.assertTrue(first.get('success')) + second = self._tap('04:A2:B5:62:AC:01') + self.assertEqual(second.get('error'), 'debounce')