Files
Odoo-Modules/fusion_login_audit/tests/test_security.py
gsinghpal 482f12256e test(fusion_login_audit): view-visibility checks for admin vs non-admin
Asserts the smart-button and Login Activity tab fields are stripped
from res.users get_view() for non-admin users, and present for
Settings admins. Locks down the contract behind the
groups="base.group_system" XML attributes on the form-inheritance
view (the inherited view record cannot carry groups itself per
CLAUDE.md rule #11; the gate must live on the inner nodes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:03:59 -04:00

130 lines
6.8 KiB
Python

# -*- coding: utf-8 -*-
from odoo.exceptions import AccessError
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestFusionLoginAuditSecurity(TransactionCase):
"""Tests for the layered protection on `fusion.login.audit`:
Layer 1 — ACL (security/ir.model.access.csv): grants read-only access to
`base.group_system` and nothing to any other group. Blocks write/create/
unlink for everyone via the ORM regardless of `sudo()`.
Layer 2 — Record rule (security/security.xml): group-specific rule that
grants admins an unrestricted domain (`[(1,'=',1)]`). The rule does NOT
actively restrict non-admins — Odoo's semantics for a group-scoped rule
is "the rule only applies to users in that group". Non-admins are gated
purely by the ACL, which denies them everything. The rule's value is
documentation + future-proofing (it keeps admin access explicit if the
ACL is ever loosened with a per-group read row; the admin path remains
explicit and self-documenting). It is NOT a security gate the ACL relies on.
Test naming reflects which layer actually does the work:
- test_acl_blocks_* — exercises Layer 1 (ACL alone is sufficient).
- test_admin_can_read_through_acl_and_rule — exercises both layers in
the positive path (admin must satisfy ACL grant
AND the admin-scoped rule's domain).
"""
def setUp(self):
super().setUp()
self.audit_row = self.env['fusion.login.audit'].sudo().create({
'attempted_login': 'sec-test@example.com',
'result': 'success',
'database': self.env.cr.dbname,
})
# Internal non-admin user (active employee, not a Settings admin).
self.regular_user = self.env['res.users'].sudo().create({
'name': 'Regular Tester',
'login': 'regular-tester@example.com',
'password': 'regular-tester-pw-1',
'group_ids': [(6, 0, [self.env.ref('base.group_user').id])],
})
# Portal user (share=True) — must not see audit data either.
self.portal_user = self.env['res.users'].sudo().create({
'name': 'Portal Tester',
'login': 'portal-tester@example.com',
'password': 'portal-tester-pw-1',
'group_ids': [(6, 0, [self.env.ref('base.group_portal').id])],
})
def test_admin_can_read_through_acl_and_rule(self):
"""A Settings admin satisfies both the ACL (grants read) and the
record rule (admin-only domain), so the read succeeds."""
admin = self.env.ref('base.user_admin')
rec = self.audit_row.with_user(admin).read(['attempted_login'])
self.assertEqual(rec[0]['attempted_login'], 'sec-test@example.com')
def test_acl_blocks_read_for_regular_user(self):
"""A `base.group_user` member has no ACL grant on the model. The
ACL alone denies the read; the record rule never gets consulted."""
with self.assertRaises(AccessError):
self.audit_row.with_user(self.regular_user).read(['attempted_login'])
def test_acl_blocks_read_for_portal_user(self):
"""A `base.group_portal` (share=True) user has no ACL grant either.
Audit data must never leak to a portal user — IP and attempted_login
are sensitive."""
with self.assertRaises(AccessError):
self.audit_row.with_user(self.portal_user).read(['attempted_login'])
def test_acl_blocks_write_for_admin(self):
"""Even Settings admins cannot write — the ACL grants no group any
write permission on this model (audit log is append-only). The rule's
`perm_write=False` means 'rule does not constrain this op', so this
denial is the ACL's work alone."""
admin = self.env.ref('base.user_admin')
with self.assertRaises(AccessError):
self.audit_row.with_user(admin).write({'attempted_login': 'tampered'})
def test_acl_blocks_unlink_for_admin(self):
"""Append-only also at the unlink boundary. ACL grants no group
delete permission; the record rule's `perm_unlink=False` exempts
it from gating this op."""
admin = self.env.ref('base.user_admin')
with self.assertRaises(AccessError):
self.audit_row.with_user(admin).unlink()
# Note: a "rule actively blocks non-admins" test was attempted but
# removed once the actual Odoo semantics were verified. A group-scoped
# rule (groups=[base.group_system]) only applies to users in that group.
# Granting a base.group_user member an ACL read row would let them read
# rows — the rule does not filter them. To make the rule truly restrictive
# we would need a global rule (groups=[]) with domain [(0,'=',1)] paired
# with the admin grant. That is a security-model redesign and out of
# scope for T3. The ACL already provides the actual gate.
# ─────────────────────────────────────────────────────────────────────
# T14: view-level visibility checks. The smart button and the "Login
# Activity" tab on res.users are gated by groups="base.group_system"
# on the inner XML nodes (the inherited view record itself cannot
# carry groups — CLAUDE.md rule #11). Verify the gate works by asking
# for the form view as a non-admin and confirming the x_fc_* fields
# are stripped from the arch.
# ─────────────────────────────────────────────────────────────────────
def test_view_hides_button_and_tab_for_non_admin(self):
"""A regular user's get_view() does not contain the x_fc_login_audit_*
fields — they live behind groups="base.group_system" XML attributes."""
view = self.env['res.users'].with_user(self.regular_user).get_view(
view_id=self.env.ref('base.view_users_form').id,
view_type='form',
)
arch = view['arch']
self.assertNotIn('x_fc_login_audit_count', arch,
"Smart-button field must not leak into non-admin view")
self.assertNotIn('x_fc_login_audit_ids', arch,
"Login Activity tab must not leak into non-admin view")
def test_view_shows_button_and_tab_for_admin(self):
"""A Settings admin DOES see both nodes."""
admin = self.env.ref('base.user_admin')
view = self.env['res.users'].with_user(admin).get_view(
view_id=self.env.ref('base.view_users_form').id,
view_type='form',
)
arch = view['arch']
self.assertIn('x_fc_login_audit_count', arch)
self.assertIn('x_fc_login_audit_ids', arch)