agnes-the-ai-analyst/tests/test_web_catalog_unified.py
ZdenekSrotyr 62336bfd32
fix(rbac): stack-gated analyst access + first-demo polish (#333 follow-up) (#356)
* fix(rbac): stack-gate analyst table access via data_packages exclusively

Previously analysts could see a table in ``agnes catalog`` /
``/api/sync/manifest`` either by:
  1. being in a group with ``resource_grants(group, 'table', id)``, or
  2. being in a group with ``resource_grants(group, 'data_package', …)``
     for a package containing the table.

Path 1 leaked: admins who minted a per-table grant without ever
wrapping the table in a data_package still shipped the table to
analysts — directly contradicting the unified-stack mental model
("the stack is the unit of access"). User report:
"i když to admin nedal do data package tak to by default uživatelé
dostali to by se nemělo stát".

New policy: analyst visibility is strictly stack-gated. A table is
visible iff at least one data_package containing it is in the
analyst's stack (required ∪ subscribed). Admin god-mode and the three
internal data-source tables (agnes_sessions / _telemetry / _audit
with row-level RBAC) keep their existing carve-outs.

Touched surfaces:
* ``src/rbac.can_access_table`` + ``get_accessible_tables`` —
  routed through ``StackResolver.stack(user, DATA_PACKAGE)`` +
  ``data_package_tables`` join instead of ``resource_grants(table)``.
* ``app/api/sync._build_direct_tables_section`` — always returns
  ``[]`` (key kept for older CLI destructuring); per-table grants
  no longer manifest.
* Standardised 403 detail across ``/api/data/*``, ``/api/query``,
  ``/api/v2/sample``, ``/api/v2/scan``, ``/api/v2/schema``:
  ``Table 'X' is not in your stack. Ask an admin to add it to a
  Data Package you have access to (Required or in your stack),
  then run `agnes pull` to refresh.`` Single source of truth lives
  in ``src.rbac.table_not_in_stack_message`` so the wording stays
  consistent across CLI surfaces.

UX side: ``/catalog/t/<id>`` (table detail page) dropped the four
editorial sections (Sample questions, What's inside, Things to know,
Pairs well with) per user feedback — the page's job is now
"what is this table, where do I find it" (hero + parent packages).

Tests:
* ``tests/conftest.grant_table_via_package`` / ``revoke_table_via_package``
  — shared helpers that wrap a table in an auto-named data_package +
  grant the package required to a custom group. Replaces the legacy
  per-test ``_grant_table_to_analyst`` table-grant pattern.
* All 17 previously-failing legacy tests (test_access_control,
  test_journey_rbac, test_audit_gap_*, test_rbac, …) migrated to use
  the new helper; logic stays the same.
* ``tests/fixtures/analyst_bootstrap._grant_table_access`` updated
  to wrap via data_package so the ``test_pat`` fixture's "two table
  grants" semantics still ship parquets through ``agnes init``.
* New ``tests/test_table_not_in_stack_message.py`` locks in the
  standardised 403 detail across the data + check-access endpoints.

5204 tests passing (added 1).

* fix(catalog): first-demo UX feedback — required-first grouping + longer card description

Two minor polish items from the 2026-05-19 stakeholder demo:

1. Required packages cluster at the top of the Browse grid instead of
   being interleaved by ``created_at``. Sort key
   ``(requirement != 'required', name)`` runs before the adapter
   call in both /catalog (data_packages) and /corporate-memory
   (memory_domains) so the required block is visible without
   scrolling. Regression test pins the order via
   ``data-id="…"`` position in rendered HTML.

2. ``.stack-card__desc`` line clamp bumped 2 → 4 lines. Two-line clamp
   trailed almost every admin-authored description off in "…" before
   the second clause, forcing a click-through to read it. The detail
   page (/catalog/p/<slug>) keeps the unclamped body for longer
   content.

* release: 0.55.3 — stack-gated analyst RBAC (BREAKING) + first-demo UX polish + #345 A/B/C/D + #347 UI consistency
2026-05-19 17:01:14 +02:00

176 lines
7 KiB
Python

"""GET /catalog — unified Browse / My Stack card grid (Task 8.2 of v49 plan).
The page replaces the old per-source-card layout with marketplace.html
parity: hero + tab strip + filter chips + search + card grid using the
shared `_stack_card.html` macro. Per-table drill-down moves into
/catalog/p/<slug> (Task 8.3).
These tests render the page with seeded users + grants and assert the
new structure (tabs, chips, cards, empty banner) without asserting
on legacy markup.
"""
from __future__ import annotations
def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _make_pkg(slug: str = "sales-bundle", name: str = "Sales bundle"):
"""Create a data package and return its id."""
from src.db import get_system_db
from src.repositories.data_packages import DataPackagesRepository
conn = get_system_db()
try:
pkg_id = DataPackagesRepository(conn).create(
name=name,
slug=slug,
description=f"{name} desc",
icon="📦",
color="#fce7f3",
created_by="test",
)
finally:
conn.close()
return pkg_id
def _grant(group_name: str, resource_type: str, resource_id: str,
requirement: str = "available", users: list[str] | None = None):
"""Add a resource_grants row for the named user-group.
Also ensure ``users`` (typically the test's analyst id) are members of
the group so the resolver picks up the grant — seeded_app puts only
admin1 in the Admin group; everybody else has zero memberships by
default in the test fixture.
"""
import uuid
from src.db import get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
conn = get_system_db()
try:
gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [group_name]
).fetchone()
if not gid:
return
group_id = gid[0]
if users:
members = UserGroupMembersRepository(conn)
for u in users:
try:
members.add_member(u, group_id, source="test")
except Exception:
pass
conn.execute(
"INSERT INTO resource_grants(id, group_id, resource_type, resource_id, "
"requirement, assigned_at, assigned_by) "
"VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 'test')",
[str(uuid.uuid4()), group_id, resource_type, resource_id, requirement],
)
finally:
conn.close()
class TestCatalogUnifiedPage:
def test_admin_sees_hero_and_tabs(self, seeded_app):
"""Hero + tab strip (Browse / My Stack) + filter chips + grid container
all render for admin (who sees every package via god-mode)."""
_make_pkg("admin-test-pkg-1", "Sales bundle")
c = seeded_app["client"]
token = seeded_app["admin_token"]
resp = c.get("/catalog", headers=_auth(token))
assert resp.status_code == 200
body = resp.text
# Hero + tab strip mirrors marketplace.html structure.
assert "Data Packages" in body
# Browse / My Stack tabs.
assert "Browse" in body
assert "My Stack" in body
# Filter chips.
assert "All" in body
assert "Required" in body
# Grid container present (rendered even if empty).
assert "stack-grid" in body or "stack-empty" in body
def test_analyst_with_required_grant_sees_package_card(self, seeded_app):
"""Required grant for the analyst's Everyone group surfaces the
package on Browse with the Required badge."""
pkg_id = _make_pkg("eng-bundle", "Engineering bundle")
_grant("Everyone", "data_package", pkg_id, requirement="required",
users=["analyst1"])
c = seeded_app["client"]
token = seeded_app["analyst_token"]
resp = c.get("/catalog", headers=_auth(token))
assert resp.status_code == 200
body = resp.text
assert "Engineering bundle" in body
# Required state.
assert "is-required" in body
def test_analyst_no_grants_sees_empty_state_banner(self, seeded_app):
"""Without any data_package grant, the analyst lands on the empty
banner — no cards, explicit CTA."""
c = seeded_app["client"]
token = seeded_app["analyst_token"]
resp = c.get("/catalog", headers=_auth(token))
assert resp.status_code == 200
body = resp.text
# Empty banner copy hints at the admin-grant path.
assert "ask your admin" in body.lower() or "No data packages" in body
def test_card_buttons_carry_data_action_attrs(self, seeded_app):
"""JS wiring for Add/Remove rides on data-action attributes."""
pkg_id = _make_pkg("avail-pkg", "Available pkg")
_grant("Everyone", "data_package", pkg_id, requirement="available",
users=["analyst1"])
c = seeded_app["client"]
token = seeded_app["analyst_token"]
resp = c.get("/catalog", headers=_auth(token))
assert resp.status_code == 200
body = resp.text
# Available + not subscribed → Add button with data-action="add".
assert 'data-action="add"' in body
def test_required_packages_render_before_available_ones(self, seeded_app):
"""Browse grid groups Required cards first (first-demo feedback).
Three packages: two available + one required. The required card
must come BEFORE the available ones in the rendered HTML so it
clusters at the top of the grid instead of being interleaved by
creation order.
"""
# Seed in deliberately-wrong order (available first) so the sort
# has something to undo.
avail_pkg = _make_pkg("a-avail", "AAA Available")
req_pkg = _make_pkg("z-req", "ZZZ Required")
avail_pkg_2 = _make_pkg("m-avail", "MMM Available")
_grant("Everyone", "data_package", avail_pkg,
requirement="available", users=["analyst1"])
_grant("Everyone", "data_package", req_pkg,
requirement="required", users=["analyst1"])
_grant("Everyone", "data_package", avail_pkg_2,
requirement="available", users=["analyst1"])
resp = seeded_app["client"].get(
"/catalog", headers=_auth(seeded_app["analyst_token"]),
)
body = resp.text
# The required-grant card must appear earlier in the document
# than either available card — independent of creation order or
# alphabetical name ordering.
i_req = body.find('data-id="' + req_pkg + '"')
i_a1 = body.find('data-id="' + avail_pkg + '"')
i_a2 = body.find('data-id="' + avail_pkg_2 + '"')
assert i_req != -1 and i_a1 != -1 and i_a2 != -1
assert i_req < i_a1, (
"Required card must render before available card 'AAA' "
f"(req@{i_req}, avail@{i_a1})"
)
assert i_req < i_a2, (
"Required card must render before available card 'MMM' "
f"(req@{i_req}, avail@{i_a2})"
)