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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user