Add _match_api_key() class method to fusion.billing.service, with a TDD test suite (TestServiceApiKey) covering key generation, hash storage, positive match, and rejection of bad/inactive keys. Also fix fcb_test_on_trial.sh to use --http-port 8070, as Odoo 19 forces http_spawn() even under --no-http when --test-enable is set. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
65 lines
2.3 KiB
Python
65 lines
2.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
import hashlib
|
|
import secrets
|
|
|
|
from odoo import api, fields, models
|
|
|
|
|
|
class FusionBillingService(models.Model):
|
|
"""A source app that pushes billing data (NexaCloud / NexaDesk / NexaMaps).
|
|
|
|
The bearer API key is shown ONCE on generation and stored only as a SHA-256
|
|
hash. This record is the auth + routing boundary for the inbound API and the
|
|
target for outbound webhooks. See spec §5.1 / §7 / §8.
|
|
"""
|
|
|
|
_name = "fusion.billing.service"
|
|
_description = "Fusion Billing — Source Service"
|
|
_order = "name"
|
|
|
|
name = fields.Char(required=True)
|
|
code = fields.Char(
|
|
required=True, index=True,
|
|
help="Stable code the app identifies itself with, e.g. nexacloud / nexadesk / nexamaps.",
|
|
)
|
|
active = fields.Boolean(default=True)
|
|
|
|
api_key_hash = fields.Char(
|
|
string="API Key (SHA-256)",
|
|
help="Hash of the bearer key. The raw key is displayed once at generation time.",
|
|
)
|
|
webhook_url = fields.Char(help="Endpoint this app exposes to receive billing webhooks.")
|
|
webhook_secret = fields.Char(help="Shared secret for HMAC-SHA256 webhook signatures.")
|
|
|
|
account_link_ids = fields.One2many(
|
|
"fusion.billing.account.link", "service_id", string="Customer Links",
|
|
)
|
|
account_link_count = fields.Integer(compute="_compute_account_link_count")
|
|
|
|
_code_uniq = models.Constraint("unique(code)", "Service code must be unique.")
|
|
|
|
@api.depends("account_link_ids")
|
|
def _compute_account_link_count(self):
|
|
for rec in self:
|
|
rec.account_link_count = len(rec.account_link_ids)
|
|
|
|
def action_generate_api_key(self):
|
|
"""Generate a fresh bearer key, store only its hash, return the raw key.
|
|
|
|
TODO(spec §7): surface the raw key once in the UI (wizard/notification).
|
|
"""
|
|
self.ensure_one()
|
|
raw = secrets.token_urlsafe(32)
|
|
self.api_key_hash = hashlib.sha256(raw.encode()).hexdigest()
|
|
return raw
|
|
|
|
@api.model
|
|
def _match_api_key(self, raw_key):
|
|
"""Return the active service whose stored hash matches raw_key, else empty recordset."""
|
|
if not raw_key:
|
|
return self.browse()
|
|
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
|
return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1)
|