agnes-the-ai-analyst/tests/test_groups_mapped_email.py
minasarustamyan d4ac84dd46
feat(rbac): drop dataset_permissions + users.role + is_public; v19 migration (#150)
* feat(rbac): drop dataset_permissions + access_requests + users.role + is_public; v19 migration

BREAKING. Sjednocení datové RBAC vrstvy do per-group resource_grants modelu.
Před PR byla legacy data RBAC vrstva (dataset_permissions + is_public bypass)
de-facto neaktivní — is_public neměl API/UI/CLI surface, default true znamenal
že can_access_table vždycky bypassl. Dnes každý non-admin přístup vyžaduje
explicitní resource_grants(group, "table", id) řádek.

Schema v18 → v19 (src/db.py:_v18_to_v19_finalize):
- DROP TABLE dataset_permissions, access_requests
- DROP COLUMN users.role (NULL artifact since v13)
- DROP COLUMN table_registry.is_public
- Drops přes table-rebuild idiom (rename → create new → INSERT … SELECT
  → drop old) kvůli DuckDB ALTER DROP COLUMN limitacím na tabulkách
  s historic FK constraints. INSERT picks intersection sloupců, takže
  test fixtures s minimal pre-v19 schemou migrate cleanly.

Runtime:
- src/rbac.py:can_access_table → deleguje na app.auth.access.can_access
- DatasetPermissionRepository, AccessRequestRepository smazány
- AGNES_ENABLE_TABLE_GRANTS env-gate v app/resource_types.py odstraněn
  (TABLE je unconditionally enabled)

API drop:
- app/api/permissions.py, app/api/access_requests.py celé soubory
- /admin/permissions web route + admin_permissions.html
- "Request Access" modal v catalog.html + locked-row UI
- ~10 if user.get("role") != "admin" checků nahrazeno (admin shortcut
  je uvnitř can_access_table)
- /api/settings: drop permissions field z GET; PUT /api/settings/dataset
  gate přepnut na can_access(user_id, "table", dataset, conn)

Auth:
- app/auth/jwt.py:create_access_token: drop role parametr (claim zmizí
  z nově vydávaných JWT; staré tokeny zůstávají valid, claim ignored)
- app/api/users.py: drop role z CreateUserRequest / UpdateUserRequest
  (admin promotion = explicit add to Admin group via memberships API)
- src/repositories/users.py: drop role z create() / update()

CLI:
- da admin set-role smazán → hard-fail s replacement command
- da admin add-user --role flag pryč
- da auth import-token --role flag pryč
- da auth whoami: drop "Role:" výpis
- cli/config.py:save_token: role parametr now optional, no longer written
  (back-compat se starými token.json soubory zachována — pole se ignoruje)

Tests:
- DELETE: test_permissions.py, test_permissions_api.py, test_access_requests_api.py
- REWRITE: test_access_control.py (resource_grants flow), test_rbac.py
  (can_access_table over resource_grants), test_journey_rbac.py
  (drop access-request flow), test_resource_types.py (drop env-gate
  tests, drop is_public from helpers), test_v2_*.py (drop role-based
  user dicts in favor of id-based + Admin group membership),
  test_settings_api.py (no permissions field, can_access gate)
- TRIVIAL: ~30 souborů — drop role="admin" arg z UserRepository.create
  a 3rd positional role z create_access_token
- NEW: test_v18_to_v19 migration test (test_db.py),
  test_can_access_table_no_implicit_public (test_rbac.py),
  test_admin_set_role_returns_hardfail (test_cli_admin.py)
- OpenAPI snapshot regenerated

Docs:
- CHANGELOG: BREAKING entry pod [Unreleased]
- CLAUDE.md: schema v18 → v19
- docs/architecture.md: schema table + RBAC sekce přepsána
- docs/auth-google-oauth.md: admin promotion přes da admin break-glass
- cli/skills/security.md: kompletně přepsáno na group-based model
- docs/TODO-rbac-data-enforcement.md: smazáno (TODO splněn)

Test results: 2363 passed, 19 failed. Zbývající failures jsou pre-existing
Windows-specific issues (fcntl, charset) nesouvisející s tímto PR —
ověřeno git stash pop.

Plan: ~/.claude/plans/floofy-coalescing-parnas.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(release): cut 0.27.0

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-04-30 22:02:16 +02:00

699 lines
27 KiB
Python

"""Tests for /api/admin/groups origin + mapped_email surface.
Covers the admin-UI rule: when AGNES_GROUP_ADMIN_EMAIL /
AGNES_GROUP_EVERYONE_EMAIL map a Workspace group onto the seeded Admin /
Everyone system row, the row carries:
- ``origin = 'google_sync'`` (the seed badge is suppressed —
Workspace is the authoritative source for membership)
- ``mapped_email`` = the Workspace group email
so the list / detail templates can render `Admin / admins@workspace.test`
with a green `google_sync` chip instead of `Admin / Admin` with the
yellow system chip. Without the env mapping, the same row stays a plain
`'system'` with no mapped_email.
"""
import tempfile
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def fresh_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
monkeypatch.setenv("DATA_DIR", tmp)
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
from src.db import close_system_db
close_system_db()
yield tmp
close_system_db()
def _seed_admin():
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
conn = get_system_db()
try:
uid = str(uuid.uuid4())
UserRepository(conn).create(id=uid, email="admin@test", name="Admin")
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
UserGroupMembersRepository(conn).add_member(uid, admin_gid, source="system_seed")
return uid, create_access_token(user_id=uid, email="admin@test")
finally:
conn.close()
def _groups_by_name(client: TestClient, token: str) -> dict:
"""Fetch /api/admin/groups, return {name: row} for assertion brevity."""
resp = client.get("/api/admin/groups", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200, resp.text
return {g["name"]: g for g in resp.json()}
def test_admin_row_origin_is_google_sync_when_env_mapped(fresh_db, monkeypatch):
"""When AGNES_GROUP_ADMIN_EMAIL is set, the seeded Admin row reports
origin='google_sync' — the system badge is suppressed because
Workspace is the authoritative source of membership for this row."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
groups = _groups_by_name(client, token)
admin = groups["Admin"]
assert admin["origin"] == "google_sync"
assert admin["mapped_email"] == "admins@workspace.test"
assert admin["is_google_managed"] is True
def test_everyone_row_origin_is_google_sync_when_env_mapped(fresh_db, monkeypatch):
monkeypatch.setenv("AGNES_GROUP_EVERYONE_EMAIL", "everyone@workspace.test")
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
groups = _groups_by_name(client, token)
everyone = groups["Everyone"]
assert everyone["origin"] == "google_sync"
assert everyone["mapped_email"] == "everyone@workspace.test"
assert everyone["is_google_managed"] is True
def test_admin_row_is_plain_system_without_env_mapping(fresh_db):
"""Without AGNES_GROUP_ADMIN_EMAIL set, the seeded Admin row is just a
regular system row — system chip, no mapped_email."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
groups = _groups_by_name(client, token)
admin = groups["Admin"]
assert admin["origin"] == "system"
assert admin["mapped_email"] is None
assert admin["is_google_managed"] is False
def test_user_created_google_sync_group_origin(fresh_db):
"""A Workspace-derived group whose `name` is the email itself reports
origin='google_sync' and has null mapped_email — the email is already
the canonical name."""
from app.main import app
from src.db import get_system_db
from src.repositories.user_groups import UserGroupsRepository
conn = get_system_db()
try:
UserGroupsRepository(conn).create(
name="finance@workspace.test",
created_by="system:google-sync",
)
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
groups = _groups_by_name(client, token)
g = groups["finance@workspace.test"]
assert g["origin"] == "google_sync"
assert g["mapped_email"] is None
assert g["is_google_managed"] is True
def test_admin_created_custom_group_origin(fresh_db):
"""Admin-created groups report origin='custom' — the value is named
after the *origin* of the row, not the creator's role, so the chip
doesn't visually clash with the seeded `Admin` system group."""
from app.main import app
from src.db import get_system_db
from src.repositories.user_groups import UserGroupsRepository
conn = get_system_db()
try:
UserGroupsRepository(conn).create(name="data-team", created_by="admin@test")
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
groups = _groups_by_name(client, token)
g = groups["data-team"]
assert g["origin"] == "custom"
assert g["mapped_email"] is None
assert g["is_google_managed"] is False
# ── UI ────────────────────────────────────────────────────────────────────
def test_admin_groups_template_uses_mapped_email_in_subtitle(fresh_db):
"""List view JS must consult `g.mapped_email` for the subtitle so
mapped Admin/Everyone show the Workspace email under the canonical
name instead of `Admin / Admin`."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/admin/groups",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200
body = resp.text
assert "g.mapped_email" in body
def test_access_overview_returns_origin_and_mapped_email(fresh_db, monkeypatch):
"""`/api/admin/access-overview` powers the /admin/access sidebar; the
groups payload must carry the same origin / mapped_email / is_google_managed
fields the dedicated /api/admin/groups endpoint exposes, so the sidebar
can render the identical pill + subtitle treatment."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/api/admin/access-overview",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
by_name = {g["name"]: g for g in data["groups"]}
admin = by_name["Admin"]
assert admin["origin"] == "google_sync"
assert admin["mapped_email"] == "admins@workspace.test"
assert admin["is_google_managed"] is True
everyone = by_name["Everyone"]
assert everyone["origin"] == "system"
assert everyone["mapped_email"] is None
assert everyone["is_google_managed"] is False
def test_admin_access_template_renders_origin_pill_and_mapped_email(fresh_db, monkeypatch):
"""The /admin/access page JS must read `origin` / `mapped_email` from
each group so the sidebar gets the same pill + subtitle as
/admin/groups. Pin the JS contract so a renderer regression that
drops the consult on these fields fails CI."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/admin/access",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# JS reads these fields per group when rendering the sidebar.
assert "g.origin" in body
assert "g.mapped_email" in body
assert "g.is_google_managed" in body
# Origin chip CSS classes (multi-color) must be present so the pill renders.
assert ".origin-google_sync" in body
assert ".origin-system" in body
assert ".origin-custom" in body
def test_user_groups_payload_carries_origin(fresh_db, monkeypatch):
"""`/api/users` returns each membership chip's origin so the user-list
page can color the pill (yellow / gray / green / purple) without a
second fetch."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, SYSTEM_EVERYONE_GROUP, get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.user_groups import UserGroupsRepository
from src.repositories.users import UserRepository
conn = get_system_db()
try:
# Target user belongs to: Admin (mapped → google_sync), Everyone
# (system, unmapped), data-team (custom), eng@workspace.test (google_sync).
ug_repo = UserGroupsRepository(conn)
custom_g = ug_repo.create(name="data-team", created_by="admin@test")
gsync_g = ug_repo.create(name="eng@workspace.test", created_by="system:google-sync")
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
everyone_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_EVERYONE_GROUP]
).fetchone()[0]
target_uid = str(uuid.uuid4())
UserRepository(conn).create(
id=target_uid, email="t@test", name="T",
)
members = UserGroupMembersRepository(conn)
members.add_member(target_uid, admin_gid, source="google_sync")
members.add_member(target_uid, everyone_gid, source="admin")
members.add_member(target_uid, custom_g["id"], source="admin")
members.add_member(target_uid, gsync_g["id"], source="google_sync")
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
resp = client.get("/api/users", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
target = next(u for u in resp.json() if u["id"] == target_uid)
by_name = {g["name"]: g for g in target["groups"]}
# Admin row is env-mapped → origin='google_sync' (matches /api/admin/groups).
assert by_name["Admin"]["origin"] == "google_sync"
# Everyone has no env mapping → stays 'system'.
assert by_name["Everyone"]["origin"] == "system"
# Custom + google-sync user-created groups carry their respective tags.
assert by_name["data-team"]["origin"] == "custom"
assert by_name["eng@workspace.test"]["origin"] == "google_sync"
def test_user_memberships_payload_carries_origin(fresh_db, monkeypatch):
"""`/api/admin/users/{id}/memberships` must carry `origin` so the
user detail page can color-code the membership chips identically to
the user list."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.user_groups import UserGroupsRepository
from src.repositories.users import UserRepository
conn = get_system_db()
try:
ug_repo = UserGroupsRepository(conn)
custom_g = ug_repo.create(name="data-team", created_by="admin@test")
gsync_g = ug_repo.create(name="legal@workspace.test", created_by="system:google-sync")
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
target_uid = str(uuid.uuid4())
UserRepository(conn).create(
id=target_uid, email="t@test", name="T",
)
members = UserGroupMembersRepository(conn)
members.add_member(target_uid, admin_gid, source="google_sync")
members.add_member(target_uid, custom_g["id"], source="admin")
members.add_member(target_uid, gsync_g["id"], source="google_sync")
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
f"/api/admin/users/{target_uid}/memberships",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
by_name = {m["group_name"]: m for m in resp.json()}
# env-mapped Admin → google_sync (matches /api/admin/groups behavior)
assert by_name["Admin"]["origin"] == "google_sync"
assert by_name["data-team"]["origin"] == "custom"
assert by_name["legal@workspace.test"]["origin"] == "google_sync"
def test_add_user_to_group_response_carries_origin(fresh_db):
"""POST /api/admin/users/{id}/memberships must compute `origin` the
same way GET does. Without this, any caller relying on the POST
response (or rendering the chip optimistically before the GET
re-fetch) sees `'custom'` even when adding to the seeded Admin /
Everyone system rows.
"""
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.user_groups import UserGroupsRepository
from src.repositories.users import UserRepository
conn = get_system_db()
try:
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
custom_g = UserGroupsRepository(conn).create(
name="data-team", created_by="admin@test",
)
target_uid = str(uuid.uuid4())
UserRepository(conn).create(
id=target_uid, email="t@test", name="T",
)
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
headers = {"Authorization": f"Bearer {token}"}
# System group (no env mapping) → origin must be 'system', not the
# default 'custom'.
resp = client.post(
f"/api/admin/users/{target_uid}/memberships",
headers=headers,
json={"group_id": admin_gid},
)
assert resp.status_code == 201, resp.text
assert resp.json()["origin"] == "system"
# Custom admin-created group → origin stays 'custom'.
resp = client.post(
f"/api/admin/users/{target_uid}/memberships",
headers=headers,
json={"group_id": custom_g["id"]},
)
assert resp.status_code == 201, resp.text
assert resp.json()["origin"] == "custom"
def test_effective_access_lists_explicit_grants_for_admin_user(fresh_db):
"""`/api/admin/users/{id}/effective-access` no longer short-circuits
for admins — they get the same per-resource breakdown as everyone
else, so an operator auditing a target user can see precisely which
grants the Admin group carries via which group, instead of a flat
"Full access" pill that hides the wiring. Authorization at runtime
still gives Admin god-mode regardless of this list."""
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.resource_grants import ResourceGrantsRepository
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.user_groups import UserGroupsRepository
from src.repositories.users import UserRepository
# Set up: a target admin user belongs to the Admin group AND a
# custom "data-team" group. The Admin group has no explicit grants;
# data-team has one. The endpoint should list the data-team grant.
conn = get_system_db()
try:
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
custom_g = UserGroupsRepository(conn).create(
name="data-team", created_by="admin@test",
)
target_uid = str(uuid.uuid4())
UserRepository(conn).create(
id=target_uid, email="t-admin@test", name="T",
)
members = UserGroupMembersRepository(conn)
members.add_member(target_uid, admin_gid, source="admin")
members.add_member(target_uid, custom_g["id"], source="admin")
ResourceGrantsRepository(conn).create(
group_id=custom_g["id"],
resource_type="plugin",
resource_id="agnes/foo",
)
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
f"/api/admin/users/{target_uid}/effective-access",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
# is_admin still reflects reality; the UI just doesn't short-circuit on it.
assert data["is_admin"] is True
# The actual grant list is no longer empty — `data-team` carries one.
rids = [it["resource_id"] for it in data["items"]]
assert "agnes/foo" in rids
def test_profile_template_renders_color_coded_membership_chips(fresh_db, monkeypatch):
"""The /profile page must render group memberships with the same
chip vocabulary used on the user list / detail pages: a colored
.group-chip with class derived from name (Admin / Everyone) first
and origin (google_sync / custom) second. google_sync chip text is
shortened via the prefix-strip logic and the raw email sits on the
chip's title attribute for hover reveal."""
monkeypatch.setenv("AGNES_GOOGLE_GROUP_PREFIX", "grp_acme_")
from app.main import app
from src.db import get_system_db
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.user_groups import UserGroupsRepository
admin_uid, token = _seed_admin()
conn = get_system_db()
try:
# Add the seeded admin to a Workspace-derived group so the
# rendered profile page actually has a green chip we can grep
# for. The chip text should be "Legal" (prefix stripped,
# capitalized); the title attribute should keep the raw email.
ug_repo = UserGroupsRepository(conn)
gsync = ug_repo.create(
name="grp_acme_legal@workspace.test",
created_by="system:google-sync",
)
UserGroupMembersRepository(conn).add_member(
admin_uid, gsync["id"], source="google_sync",
)
finally:
conn.close()
client = TestClient(app)
resp = client.get(
"/profile",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# Chip CSS classes from the shared vocabulary.
assert ".group-chip.is-admin" in body
assert ".group-chip.is-google_sync" in body
assert ".group-chip.is-custom" in body
# Admin row gets the canonical-name chip class (server-side rendered).
assert 'class="group-chip is-admin"' in body
# Workspace-derived group's chip text is the shortened display name;
# the raw email lives in the title attribute for hover reveal.
assert ">Legal<" in body
assert 'title="grp_acme_legal@workspace.test"' in body
def test_my_effective_access_lists_explicit_grants_for_admin_user(fresh_db):
"""`/api/me/effective-access` (the /profile page surface) mirrors
/api/admin/users/{id}/effective-access — admins see their explicit
grant breakdown rather than a flat "Full access" short-circuit. Same
rationale as the admin-side endpoint: audit the grant graph, not the
runtime god-mode."""
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
from src.repositories.resource_grants import ResourceGrantsRepository
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.user_groups import UserGroupsRepository
# _seed_admin already adds the admin to the Admin group; we layer on
# a custom group with one grant so the response actually has items.
admin_uid, token = _seed_admin()
conn = get_system_db()
try:
custom_g = UserGroupsRepository(conn).create(
name="data-team", created_by="admin@test",
)
UserGroupMembersRepository(conn).add_member(
admin_uid, custom_g["id"], source="admin",
)
ResourceGrantsRepository(conn).create(
group_id=custom_g["id"],
resource_type="plugin",
resource_id="agnes/foo",
)
finally:
conn.close()
client = TestClient(app)
resp = client.get(
"/api/me/effective-access",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["is_admin"] is True
rids = [it["resource_id"] for it in data["items"]]
assert "agnes/foo" in rids
def test_profile_template_drops_full_access_pill(fresh_db):
"""The /profile page no longer renders the gold "Full access via
Admin" empty-state for admin users — it should fall through to the
grant list (or the generic "no resource access" message). Pinning
the absence of the old branch."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/profile",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
assert "Full access via Admin" not in body
assert "You can read and write everything in Agnes regardless" not in body
def test_admin_user_detail_template_drops_full_access_pill(fresh_db):
"""The `ea-admin-pill` short-circuit branch is gone — a regression
that re-adds a special-case render for admins would slip through if
we don't pin its absence."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
target_uid = _create_user("v3@test")
resp = client.get(
f"/admin/users/{target_uid}",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# No longer references the "Full access via the Admin group" branch.
assert "Full access via the Admin group" not in body
assert "ea-admin-pill" not in body
def test_user_detail_dropdown_hides_google_managed_groups(fresh_db):
"""The "Add to group" dropdown on /admin/users/{id} must skip any
row with `is_google_managed=true` — membership for those groups is
owned by Workspace and the API 409s on POST anyway. Pin the JS
contract so a regression that drops the filter (and floods the
picker with un-grantable options) surfaces in CI."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
target_uid = _create_user("victim2@test")
resp = client.get(
f"/admin/users/{target_uid}",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# The filter sits inside the picker-population loop.
assert "g.is_google_managed" in body
def test_admin_user_detail_template_uses_color_coded_chips(fresh_db):
"""Detail page must declare the same chip CSS classes + reference
`m.origin` and `deriveDisplayName` in the membership renderer so a
regression that drops the rebuild surfaces in CI."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
target_uid = _create_user("victim@test")
resp = client.get(
f"/admin/users/{target_uid}",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# Color classes match the user list's chip vocabulary.
assert ".group-chip.is-admin" in body
assert ".group-chip.is-everyone" in body
assert ".group-chip.is-google_sync" in body
assert ".group-chip.is-custom" in body
# JS reads m.origin to pick the chip class.
assert "m.origin" in body
# google_sync chip text runs through deriveDisplayName.
assert "deriveDisplayName" in body
def _create_user(email: str) -> str:
"""Inline helper for the membership UI test — not reused above."""
import uuid as _uuid
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
try:
uid = str(_uuid.uuid4())
UserRepository(conn).create(id=uid, email=email, name="V")
return uid
finally:
conn.close()
def test_admin_users_template_renders_color_coded_chips(fresh_db):
"""Pin the JS contract: the user list assigns chip classes based on
name (Admin / Everyone) first and falls back to `is-${origin}` so
google_sync chips go green and custom chips go purple. A renderer
regression that drops the consult on g.origin would surface here.
Also pin the deriveDisplayName shortening for google-sync chips —
they must show "Legal" rather than the raw Workspace email so the
membership cell stays readable."""
from app.main import app
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
"/admin/users",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200
body = resp.text
# The four chip-color classes that style the pills.
assert ".group-chip.is-admin" in body
assert ".group-chip.is-everyone" in body
assert ".group-chip.is-google_sync" in body
assert ".group-chip.is-custom" in body
# JS reads g.origin to pick the class for non-Admin / non-Everyone rows.
assert "g.origin" in body
# google_sync chips run their name through deriveDisplayName so the
# cell shows "Legal" rather than the full Workspace email; the raw
# email goes into the chip's `title` (hover reveal).
assert "deriveDisplayName" in body
def test_admin_group_detail_template_uses_mapped_email_subtitle(fresh_db, monkeypatch):
"""Detail page Jinja must render `mapped_email` as the subtitle when
the row is the env-mapped Admin/Everyone, instead of the canonical
name (which would yield `Admin / Admin`)."""
monkeypatch.setenv("AGNES_GROUP_ADMIN_EMAIL", "admins@workspace.test")
from app.main import app
from src.db import SYSTEM_ADMIN_GROUP, get_system_db
conn = get_system_db()
try:
admin_gid = conn.execute(
"SELECT id FROM user_groups WHERE name = ?", [SYSTEM_ADMIN_GROUP]
).fetchone()[0]
finally:
conn.close()
client = TestClient(app)
_, token = _seed_admin()
resp = client.get(
f"/admin/groups/{admin_gid}",
headers={"Accept": "text/html"},
cookies={"access_token": token},
)
assert resp.status_code == 200, resp.text
body = resp.text
# The mapped Workspace email shows up as the gd-title-email subtitle.
assert "admins@workspace.test" in body
# The data attribute the JS reads to skip the deriveDisplayName rewrite.
assert 'data-mapped-email="admins@workspace.test"' in body