"""``person_visibility`` wire shape — validate / normalize / describe. The population ceiling for the External API's people surface. Pins the properties the surface's safety rests on: * an unknown key is REJECTED, never ignored — a typo that normalized to "no constraints" would silently widen the grant to the whole roster, which is the exact failure this column exists to prevent * the allowlist covers the FULL ``FilterConfig`` vocabulary, so a filter produced by the shared admin editor always round-trips * canonical form is stable (sorted, empty dimensions dropped) so an audit diff doesn't read a re-save as a change of grant * "unrestricted" has exactly one meaning, and widening-only keys (``include_offboarded`` / ``include_subtree`` / ``mode``) never count as a constraint """ from __future__ import annotations import uuid from pathlib import Path import pytest from app.api_clients.person_visibility import ( PERSON_VISIBILITY_KEYS, normalize_person_visibility, person_visibility_audit_detail, person_visibility_is_unrestricted, ) from app.core.person_filters import FilterConfig def test_none_and_empty_are_unrestricted(): assert normalize_person_visibility(None) is None assert normalize_person_visibility({}) is None # Empty dimensions collapse to the single "unrestricted" form. assert normalize_person_visibility({"departments": [], "countries": []}) is None assert person_visibility_is_unrestricted(None) assert person_visibility_is_unrestricted({}) assert person_visibility_is_unrestricted({"countries": []}) def test_unknown_key_is_rejected_not_ignored(): with pytest.raises(ValueError, match="unknown key"): # Singular "department" — the typo that would otherwise mean # "no constraints" and hand over the whole roster. normalize_person_visibility({"department": ["Engineering"]}) def test_allowlist_covers_the_whole_filter_vocabulary(): """The admin edits this through the shared ``PersonFilterEditor``, which emits every ``FilterConfig`` dimension. An allowlist narrower than the editor would reject a filter the UI itself produced.""" filter_fields = set(FilterConfig().__dataclass_fields__) assert filter_fields <= set(PERSON_VISIBILITY_KEYS), ( "person_visibility rejects filter dimension(s) the editor can emit: " f"{sorted(filter_fields - set(PERSON_VISIBILITY_KEYS))}" ) def test_canonical_form_is_stable(): pid_a, pid_b = str(uuid.uuid4()), str(uuid.uuid4()) first = normalize_person_visibility({ "countries": ["US", "CN"], "person_ids": [pid_b, pid_a], "employment_statuses": ["left", "active"], }) second = normalize_person_visibility({ "employment_statuses": ["active", "left"], "person_ids": [pid_a, pid_b], "countries": ["CN", "US"], }) # Same grant written two ways must not read as an edit in the audit. assert first == second assert first["countries"] == ["CN", "US"] assert first["person_ids"] == sorted([pid_a, pid_b]) def test_invalid_filter_is_rejected_with_context(): with pytest.raises(ValueError, match="person_visibility"): normalize_person_visibility({"mode": "xor"}) def test_widening_keys_do_not_make_it_restricted(): value = normalize_person_visibility({ "include_offboarded": True, "include_subtree": True, }) # Neither key constrains anything on its own. assert person_visibility_is_unrestricted(value or {}) def test_departments_carry_subtree_flag_and_audit_shape(): node = str(uuid.uuid4()) value = normalize_person_visibility({ "departments": [node], "include_subtree": False, }) assert value == {"departments": [node], "include_subtree": False} assert not person_visibility_is_unrestricted(value) detail = person_visibility_audit_detail(value) assert detail["person_visibility"] == "restricted" assert detail["department_count"] == 1 assert detail["include_subtree"] is False # Departments are recorded BY VALUE, not merely counted. A count alone # made the highest-leverage widening there is — one team → a whole org — # render byte-identical on both sides of the audit diff. assert detail["departments"] == [node] def test_audit_detail_distinguishes_a_widening_from_a_no_op(): """The load-bearing property: two different grants must never produce the same audit detail. Each pair below was a real from == to before the detail was made value-lossless.""" team, org = str(uuid.uuid4()), str(uuid.uuid4()) # (a) same-size department swap — one team → a whole org. a = person_visibility_audit_detail( normalize_person_visibility({"departments": [team]}), ) b = person_visibility_audit_detail( normalize_person_visibility({"departments": [org]}), ) assert a != b # (b) same-size person_ids swap. ids_x = [str(uuid.uuid4()) for _ in range(3)] ids_y = [str(uuid.uuid4()) for _ in range(3)] assert person_visibility_audit_detail( normalize_person_visibility({"person_ids": ids_x}), ) != person_visibility_audit_detail( normalize_person_visibility({"person_ids": ids_y}), ) # (c) adding include_offboarded is a real widening (departed staff # enter the ceiling), so it cannot read as unchanged. assert person_visibility_audit_detail( normalize_person_visibility({"departments": [team]}), ) != person_visibility_audit_detail( normalize_person_visibility( {"departments": [team], "include_offboarded": True}, ), ) # (d) flipping subtree on a single node widens to everything below it. # (This one held before the fix too — kept because it is part of the # stated property, not because it guards the fix.) assert person_visibility_audit_detail( normalize_person_visibility( {"departments": [team], "include_subtree": False}, ), ) != person_visibility_audit_detail( normalize_person_visibility( {"departments": [team], "include_subtree": True}, ), ) # (e) same COUNT of departments, different departments — the shape # that made a one-team → whole-org widening render as a no-op. other = str(uuid.uuid4()) assert person_visibility_audit_detail( normalize_person_visibility({"departments": [team, org]}), ) != person_visibility_audit_detail( normalize_person_visibility({"departments": [team, other]}), ) def test_audit_detail_bounds_a_large_person_list_without_hiding_its_size(): ids = [str(uuid.uuid4()) for _ in range(60)] detail = person_visibility_audit_detail( normalize_person_visibility({"person_ids": ids}), ) # Full count always present, so a change of size is never invisible… assert detail["person_count"] == 60 # …while the row stays legible. assert len(detail["person_ids"]) == 25 assert detail["person_ids_truncated"] is True # The sample cannot be reconstructed into the grant, but it also must # not be the ONLY identity the row carries — see the next test. assert len(detail["person_ids_digest"]) == 16 def test_a_swap_beyond_the_sample_window_still_changes_the_audit_detail(): """The bound must not re-open the hole it was added next to: swapping an id PAST the 25-id sample leaves count, sample and flag identical, so without a whole-list digest the audit diff reads as a no-op again. This is the case the first fix missed and the second round caught. """ ids = sorted(str(uuid.uuid4()) for _ in range(40)) before = person_visibility_audit_detail( normalize_person_visibility({"person_ids": ids}), ) # Replace one id that sorts beyond the sample window. swapped = [*ids[:-1], str(uuid.uuid4())] after = person_visibility_audit_detail( normalize_person_visibility({"person_ids": swapped}), ) assert sorted(ids) != sorted(swapped) assert before["person_count"] == after["person_count"] == 40 assert before != after assert before["person_ids_digest"] != after["person_ids_digest"] def test_person_ids_are_canonicalized_to_the_db_uuid_form(): """An uppercase / unhyphenated UUID names a real person, but the filter engine compares ids as exact strings — stored raw it would match nobody, and the existence check would report "unknown person" about someone who is right there.""" canonical = str(uuid.uuid4()) value = normalize_person_visibility({ "person_ids": [canonical.upper(), canonical.replace("-", "")], }) assert value is not None assert value["person_ids"] == [canonical] def test_audit_detail_for_unrestricted(): assert person_visibility_audit_detail(None) == { "person_visibility": "unrestricted", } def test_audit_detail_calls_an_unreadable_ceiling_what_it_is(): """Clearing an unreadable ceiling takes a client from *reads nobody* to *reads the whole roster* — the largest widening on this surface. It must not audit as ``from == to == unrestricted``, which is what a plain is-unrestricted check produces for a non-dict value. """ broken = person_visibility_audit_detail("not-an-object") assert broken["person_visibility"] == "misconfigured" # The repair is therefore a visible change in the audit diff. assert broken != person_visibility_audit_detail(None) _REPO_ROOT = Path(__file__).resolve().parents[3] _I18N_BUNDLES = ("zh-CN", "en-US", "ja-JP") def test_every_scope_has_a_description_in_all_three_bundles(): """The scope catalog is rendered by the admin grant editor and the in-product API manual, both of which look up ``apiClients.scopeDesc.``. A scope added without its i18n keys doesn't fail loudly — it renders the raw key string to the admin who is deciding what to grant, which is the worst possible moment for the description to be missing. Backend-side because the catalog is backend-owned; the frontend guards (`check-i18n-translation-parity`) only compare bundles to each other and cannot know a new scope exists. """ from app.api_clients.scopes import SCOPE_CATALOG missing: list[str] = [] for bundle in _I18N_BUNDLES: text = (_REPO_ROOT / "web" / "src" / "i18n" / f"{bundle}.ts").read_text( encoding="utf-8", ) for spec in SCOPE_CATALOG: if f"'apiClients.scopeDesc.{spec.desc_id}'" not in text: missing.append(f"{bundle}: {spec.key} (desc_id={spec.desc_id})") assert not missing, "scope description(s) missing from i18n: " + ", ".join(missing)