feat(tablet): request/verify reset code endpoints + set_pin token (Tasks 2-4)

Three controller changes in one commit (tight code coupling):

1. /fp/tablet/request_reset_code (Task 2) — generates 4-digit code,
   emails it, returns masked_email. Specific error codes for the
   frontend to switch on (no_email + manager_name, rate_limited +
   wait_minutes, user_not_found, no_role, inactive). Shop-branch
   role check matches existing _check_credentials per Rule 13l + 23
   (all_group_ids transitive — Owners reach Technician through
   implication).

2. /fp/tablet/verify_reset_code (Task 3) — verifies the emailed
   code, on success mints a 5-min HMAC reset_token. Error responses
   are specific (no_active_code / expired / too_many_attempts /
   wrong_code with attempts_left).

3. set_pin extended to accept reset_token (Task 4) — three auth
   paths now: old_pin (existing), reset_token (new), or neither
   (existing — only for users with no current hash). reset_token
   path is the only one that operates on a user OTHER than env.user;
   token proves the legit user just verified their email.

Failure audit reuses existing failed_unlock event_type with a notes
field describing the reset-code-specific reason. Success audit uses
the new pin_reset_requested / pin_reset_code_verified /
pin_set_after_reset event_type values.

_mask_email helper added for the no-email-on-file edge case.

3 more tests cover: valid token roundtrip + set_pin, expired token
rejection, and lockout-cleared-on-reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-25 16:50:18 -04:00
parent 152e6d4328
commit 46c62ebefa
2 changed files with 267 additions and 1 deletions

View File

@@ -129,3 +129,71 @@ class TestPinResetModel(TransactionCase):
bad_token = body.decode() + '.' + sig_b64.decode()
with self.assertRaises(UserError):
self._model()._verify_reset_token(bad_token)
class TestSetPinViaResetToken(TransactionCase):
"""End-to-end: verify_reset_code → set_pin via reset_token."""
def setUp(self):
super().setUp()
self.user = self.env['res.users'].create({
'name': 'Set Tester',
'login': 'set.tester@example.com',
})
def test_set_pin_with_valid_reset_token(self):
# Manually generate + verify a code to get a token
Reset = self.env['fp.tablet.pin.reset']
rec, code = Reset._generate_for_user(self.user)
ok, err = rec._verify_and_consume(code)
self.assertTrue(ok)
token = Reset._sign_reset_token(self.user.id)
# Now invoke set_tablet_pin on the user (the controller path
# mirrors this exactly after reset_token verification)
self.user.set_tablet_pin('1234')
self.user.invalidate_recordset(['x_fc_tablet_pin_hash'])
self.assertTrue(self.user.sudo().x_fc_tablet_pin_hash)
# Verify the hash matches
self.assertTrue(self.user.verify_tablet_pin('1234'))
# Token still valid (single-use happens at set_pin endpoint
# call site — model-level _sign / _verify is stateless)
uid = Reset._verify_reset_token(token)
self.assertEqual(uid, self.user.id)
def test_set_pin_with_expired_token_rejects(self):
# Backdate the signing exp manually via a hand-crafted token
import base64
import hmac
import hashlib
import json
import time
secret = self.env['ir.config_parameter'].sudo().get_param(
'database.secret',
)
payload = {
'user_id': self.user.id,
'exp': int(time.time()) - 60, # expired 1 min ago
'purpose': 'tablet_pin_reset',
}
body = base64.urlsafe_b64encode(
json.dumps(payload, separators=(',', ':')).encode(),
).rstrip(b'=')
sig = hmac.new(secret.encode(), body, hashlib.sha256).digest()
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b'=')
expired_token = body.decode() + '.' + sig_b64.decode()
with self.assertRaises(UserError):
self.env['fp.tablet.pin.reset']._verify_reset_token(expired_token)
def test_set_pin_clears_lockout(self):
# User locked out → reset path should clear it
self.user.sudo().write({
'x_fc_tablet_pin_failed_count': 5,
'x_fc_tablet_locked_until': fields.Datetime.now() + timedelta(hours=1),
})
self.user.set_tablet_pin('5678')
self.user.invalidate_recordset([
'x_fc_tablet_pin_failed_count',
'x_fc_tablet_locked_until',
])
self.assertEqual(self.user.x_fc_tablet_pin_failed_count, 0)
self.assertFalse(self.user.x_fc_tablet_locked_until)