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