diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py index 100149dd..29f99076 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py @@ -75,3 +75,75 @@ class FpTabletController(http.Controller): target.id, env.uid, ) return {'ok': True} + + # ====================================================================== + # /fp/tablet/unlock — verify PIN + manage failure counter / lockout + # ====================================================================== + @http.route('/fp/tablet/unlock', type='jsonrpc', auth='user') + def unlock(self, user_id, pin): + env = request.env + Users = env['res.users'].sudo() # need sudo to read hash field + target = Users.browse(int(user_id)) + if not target.exists(): + return {'ok': False, 'error': _('User not found.')} + + # No PIN set yet — caller must set one first + if not target.x_fc_tablet_pin_hash: + return { + 'ok': False, + 'error': _('No PIN set. Set one in Preferences first.'), + 'needs_setup': True, + } + + # Currently locked out? + now = fields.Datetime.now() + if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now: + return { + 'ok': False, + 'error': _('Account locked. Try again in a few minutes.'), + 'locked_until': target.x_fc_tablet_locked_until.isoformat(), + } + + if target.verify_tablet_pin(pin): + # Reset failure state on success + target.write({ + 'x_fc_tablet_pin_failed_count': 0, + 'x_fc_tablet_locked_until': False, + }) + _logger.info( + "Tablet unlocked by uid %s (session uid %s)", + target.id, env.uid, + ) + return { + 'ok': True, + 'current_tech_id': target.id, + 'current_tech_name': target.name, + } + + # Wrong PIN — increment and check threshold + new_count = (target.x_fc_tablet_pin_failed_count or 0) + 1 + threshold = int(env['ir.config_parameter'].sudo().get_param( + 'fp.shopfloor.tablet_pin_fail_threshold', 5, + )) + lockout_min = int(env['ir.config_parameter'].sudo().get_param( + 'fp.shopfloor.tablet_pin_fail_lockout_minutes', 5, + )) + vals = {'x_fc_tablet_pin_failed_count': new_count} + if new_count >= threshold: + vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min) + target.write(vals) + _logger.warning( + "Tablet PIN failure for uid %s (count=%d, locked=%s)", + target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')), + ) + if vals.get('x_fc_tablet_locked_until'): + return { + 'ok': False, + 'error': _('Too many failed attempts. Locked for %d minutes.') % lockout_min, + 'locked_until': vals['x_fc_tablet_locked_until'].isoformat(), + } + return { + 'ok': False, + 'error': _('Incorrect PIN.'), + 'attempts_remaining': threshold - new_count, + } diff --git a/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py b/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py index fc9c97c3..9cfb1818 100644 --- a/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py +++ b/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py @@ -124,3 +124,60 @@ class TestTabletResetPinFor(HttpCase): self.assertTrue(res['ok']) self.target.invalidate_recordset(['x_fc_tablet_pin_hash']) self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash) + + +@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') +class TestTabletUnlock(HttpCase): + """P6.1.3 — /fp/tablet/unlock endpoint + lockout.""" + + def setUp(self): + super().setUp() + self.authenticate("admin", "admin") + self.target = self.env['res.users'].create({ + 'name': 'Unlock Target', 'login': 'unlock@example.com', + }) + self.target.sudo().set_tablet_pin('1234') + + def _unlock(self, pin): + return _rpc(self, '/fp/tablet/unlock', + user_id=self.target.id, pin=pin) + + def test_unlock_correct_pin(self): + res = self._unlock('1234') + self.assertTrue(res['ok']) + self.assertEqual(res['current_tech_id'], self.target.id) + self.assertEqual(res['current_tech_name'], 'Unlock Target') + + def test_unlock_correct_pin_resets_fail_counter(self): + self._unlock('0000') # fail once + self._unlock('1234') # succeed + self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count']) + self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 0) + + def test_unlock_wrong_pin_increments_counter(self): + self._unlock('0000') + self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count']) + self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 1) + + def test_lockout_after_5_fails(self): + for _ in range(5): + self._unlock('0000') + res = self._unlock('0000') # 6th + self.assertFalse(res['ok']) + self.assertIn('locked', res['error'].lower()) + self.target.invalidate_recordset(['x_fc_tablet_locked_until']) + self.assertTrue(self.target.sudo().x_fc_tablet_locked_until) + + def test_lockout_blocks_even_correct_pin(self): + for _ in range(5): + self._unlock('0000') + # Even the correct PIN now rejected + res = self._unlock('1234') + self.assertFalse(res['ok']) + self.assertIn('locked', res['error'].lower()) + + def test_unlock_no_pin_set(self): + self.target.clear_tablet_pin() + res = self._unlock('1234') + self.assertFalse(res['ok']) + self.assertTrue(res.get('needs_setup'))