feat(tablet_pin_reset): new model + hash helpers + token sign (Task 1)

fp.tablet.pin.reset stores hashed 4-digit codes emailed for self-
service PIN create/reset. Per CLAUDE.md Rule 24 + Rule 13l it follows
the defensive patterns established elsewhere in the shopfloor module:
  - PBKDF2-SHA256 hashing (200k iterations, matches ResUsers PIN)
  - 72h TTL per D4
  - 5 wrong-attempt cap per D5 (invalidates code, used_at set)
  - 3 requests/60min rate limit per D6 (raises UserError)
  - SQL EXCLUDE constraint enforces one-active-row-per-user per D7
  - HMAC-SHA256 reset_token (300s TTL, single-use) for step 3 of
    the flow (set_pin via reset_token alternative to old_pin)

Audit event_type extended with 3 new values (pin_reset_requested,
pin_reset_code_verified, pin_set_after_reset). Manager-only ACL on
the new model; sudo when endpoints need access.

10 model-level tests cover generate / replace-active / rate-limit /
verify-correct / verify-wrong / 5-attempt-cap / expired / token sign
roundtrip / tampered-sig / purpose-mismatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-25 16:48:45 -04:00
parent 33fff5acba
commit 152e6d4328
6 changed files with 426 additions and 0 deletions

View File

@@ -9,3 +9,4 @@ from . import test_tablet_pin_auth_manager
from . import test_unlock_lock_session_endpoints
from . import test_force_lock_cron
from . import test_tiles_bootstrap_fields
from . import test_pin_reset_flow

View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
"""Tablet PIN self-service reset-code tests.
Spec: docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md
Plan: docs/superpowers/plans/2026-05-25-tablet-pin-self-service-plan.md
"""
from datetime import timedelta
from odoo import fields
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
class TestPinResetModel(TransactionCase):
"""Model-level lifecycle tests (no HTTP)."""
def setUp(self):
super().setUp()
self.user = self.env['res.users'].create({
'name': 'Reset Tester',
'login': 'reset.tester@example.com',
})
def _model(self):
return self.env['fp.tablet.pin.reset']
def test_generate_creates_active_row(self):
rec, code = self._model()._generate_for_user(self.user)
self.assertTrue(rec.exists())
self.assertEqual(len(code), 4)
self.assertTrue(code.isdigit())
self.assertFalse(rec.used_at)
# 72h ± a few seconds
delta = rec.expires_at - fields.Datetime.now()
self.assertGreater(delta, timedelta(hours=71, minutes=59))
self.assertLess(delta, timedelta(hours=72, minutes=1))
def test_generate_replaces_prior_active(self):
rec1, _c1 = self._model()._generate_for_user(self.user)
rec2, _c2 = self._model()._generate_for_user(self.user)
# rec1 should now be marked used (forced by _generate_for_user)
rec1.invalidate_recordset(['used_at'])
self.assertTrue(rec1.used_at)
self.assertFalse(rec2.used_at)
def test_rate_limit_kicks_in_at_4th_request(self):
self._model()._generate_for_user(self.user)
self._model()._generate_for_user(self.user)
self._model()._generate_for_user(self.user)
with self.assertRaises(UserError):
self._model()._generate_for_user(self.user)
def test_verify_correct_code_succeeds(self):
rec, code = self._model()._generate_for_user(self.user)
ok, err = rec._verify_and_consume(code)
self.assertTrue(ok)
self.assertIsNone(err)
rec.invalidate_recordset(['used_at'])
self.assertTrue(rec.used_at)
def test_verify_wrong_code_increments_attempt_count(self):
rec, code = self._model()._generate_for_user(self.user)
wrong = '9999' if code != '9999' else '0000'
ok, err = rec._verify_and_consume(wrong)
self.assertFalse(ok)
self.assertEqual(err, 'wrong_code')
rec.invalidate_recordset(['attempt_count'])
self.assertEqual(rec.attempt_count, 1)
def test_5_wrong_attempts_invalidates_code(self):
rec, code = self._model()._generate_for_user(self.user)
wrong = '9999' if code != '9999' else '0000'
for i in range(4):
rec._verify_and_consume(wrong)
rec.invalidate_recordset(['attempt_count', 'used_at'])
self.assertEqual(rec.attempt_count, 4)
self.assertFalse(rec.used_at)
# 5th wrong attempt invalidates
ok, err = rec._verify_and_consume(wrong)
self.assertFalse(ok)
self.assertEqual(err, 'too_many_attempts')
rec.invalidate_recordset(['used_at'])
self.assertTrue(rec.used_at)
def test_expired_code_rejects_even_if_correct(self):
rec, code = self._model()._generate_for_user(self.user)
# Backdate expiry to past
rec.sudo().write({
'expires_at': fields.Datetime.now() - timedelta(minutes=1),
})
ok, err = rec._verify_and_consume(code)
self.assertFalse(ok)
self.assertEqual(err, 'expired')
def test_reset_token_sign_verify_roundtrip(self):
token = self._model()._sign_reset_token(self.user.id)
uid = self._model()._verify_reset_token(token)
self.assertEqual(uid, self.user.id)
def test_reset_token_tampered_signature_rejects(self):
token = self._model()._sign_reset_token(self.user.id)
# Flip a character in the signature half
body, sig = token.split('.', 1)
bad_sig = ('A' if sig[0] != 'A' else 'B') + sig[1:]
bad_token = body + '.' + bad_sig
with self.assertRaises(UserError):
self._model()._verify_reset_token(bad_token)
def test_reset_token_purpose_mismatch_rejects(self):
# Manually craft a token with wrong purpose
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()) + 300,
'purpose': 'wrong_purpose',
}
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'=')
bad_token = body.decode() + '.' + sig_b64.decode()
with self.assertRaises(UserError):
self._model()._verify_reset_token(bad_token)