diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py new file mode 100644 index 0000000..e992295 --- /dev/null +++ b/backend/tests/test_permissions.py @@ -0,0 +1,429 @@ +"""The Brief section 3 permission table, verified row by row (T-040, +Brief step 21). Every test function below is named after (and comments +the exact text of) one row of that table, or the enforcement principle +stated underneath it, so this file reads as the table: + + | Capability | Coach | Player | + |---|---|---| + | Whiteboard: lanes, zones, record and save tactics | Yes | Yes | + | Delete a saved pattern | Yes | No | + | Pattern library, formations, identity | Full | Full (view + play) | + | Roster | Full + fit warnings | View-only, no fit warnings | + | Suggest own playstyle | n/a | pending coach review (T-041) | + | Sessions | create/send/receipts | read-only + watch (T-042) | + +Principles (binding, tested explicitly at the bottom of this file): +players are additive-only; coach-only information (fit warnings, +receipts) never renders in player views rather than being disabled; a +player token calling a delete or receipt endpoint gets 403. + +This is an audit-and-enforcement ticket, not a new-surface one: rows +already covered end to end in their own router's test file (whiteboard, +roster) are re-asserted here in the table's own words rather than +re-derived from scratch, so a reviewer can check this file against +Brief section 3 line by line without cross-referencing five other files. + +Two rows -- "Suggest own playstyle" and "Sessions" -- have no API +surface yet in this codebase state: only their SQLAlchemy models exist +(app/models/roster.py PlaystyleSuggestion; app/models/sessions.py +TrainingSession/SessionItem/SessionReceipt), no router is registered for +either in app/main.py. Per Brief section 4's own build order, role +gating (step 21, this ticket) lands before the suggestion flow (step 22, +T-041) and sessions (step 23, T-042). Those two rows are marked skipped +below with a reason, not silently omitted, so the suite still names +every row of the table; T-041/T-042 must turn each skip into a real +assertion when their routes land (T-041's own ticket says as much for +the suggestion row; the same applies to sessions by the same logic). +""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +# --------------------------------------------------------------------------- +# Shared fixtures (same convention as test_roster_routes.py / test_whiteboard_ +# routes.py: each permission test file in this suite duplicates this small +# register/team/join helper block rather than importing across test files). +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _register(client: TestClient, *, email: str, role: str, display_name: str = "Test User"): + return client.post( + "/api/auth/register", + json={ + "email": email, + "password": "correct-horse-battery", + "display_name": display_name, + "role": role, + }, + ) + + +def _coach_with_team(email: str = "coach@example.com", name: str = "Coach Test") -> TestClient: + c = TestClient(app) + _register(c, email=email, role="coach", display_name=name) + c.post("/api/teams", json={"name": f"Team for {email}"}) + return c + + +def _player_on_team(coach: TestClient, email: str, name: str = "Player Test") -> TestClient: + join_code = coach.get("/api/teams/current").json()["join_code"] + p = TestClient(app) + _register(p, email=email, role="player", display_name=name) + p.post("/api/teams/join", json={"join_code": join_code}) + return p + + +_TOKENS = [ + {"id": "home-9", "side": "home", "label": "9", "pos": {"x": 60, "y": 30}}, + {"id": "away-3", "side": "away", "label": "3", "pos": {"x": 40, "y": 70}}, + {"id": "ball", "side": "ball", "label": "", "pos": {"x": 50, "y": 50}}, +] + + +def _board_snapshot(**overrides: object) -> dict: + base = { + "tokens": _TOKENS, + "confirmed_lanes": [], + "blocking_threshold": 7.0, + "marking_threshold": 10.0, + "zones_visible": { + "thirds": False, + "half_spaces": False, + "zone_14": False, + "cutback": False, + }, + } + base.update(overrides) + return base + + +_KEYFRAMES = [{"t_ms": 0, "token_id": "home-9", "x": 60.0, "y": 30.0}] + +_ATTRS = { + "pace": 3, + "passing_range": 3, + "carrying_1v1": 3, + "positional_discipline": 3, + "aerial_physical": 3, + "pressing_engine": 3, +} + + +def _player_body(**overrides: object) -> dict: + base: dict = { + "name": "New Player", + "jersey_number": 10, + "preferred_foot": "R", + "role_code": None, + "flank": None, + "awr": "med", + "dwr": "med", + "attributes": _ATTRS, + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# Row: "Whiteboard: lanes, zones, record and save tactics" -- Yes / Yes +# (lane and zone state live on the same board row PUT by; see +# test_whiteboard_routes.py for lane/zone field-level round trips, this +# just proves both roles can reach the write path at all). +# --------------------------------------------------------------------------- + + +def test_whiteboard_lanes_zones_record_and_save__coach_yes_player_yes(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com", name="Sam Player") + + # Both roles can save board state (lanes/zones live on this row). + coach_put = coach.put( + "/api/boards/current", + json=_board_snapshot(confirmed_lanes=[{"a": "home-9", "b": "away-3"}]), + ) + assert coach_put.status_code == 200 + player_put = player.put( + "/api/boards/current", json=_board_snapshot(zones_visible={ + "thirds": True, "half_spaces": False, "zone_14": False, "cutback": False, + }) + ) + assert player_put.status_code == 200 + + # Both roles can record and save into My Patterns; saved patterns are + # author-stamped (tile shows COACH or the player's own name), not by + # any client-supplied field. + coach_pattern = coach.post( + "/api/patterns", + json={"name": "Coach build", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ) + assert coach_pattern.status_code == 201 + assert coach_pattern.json()["author_role"] == "coach" + assert coach_pattern.json()["author_label"] == "COACH" + + player_pattern = player.post( + "/api/patterns", + json={"name": "Player build", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ) + assert player_pattern.status_code == 201 + assert player_pattern.json()["author_role"] == "player" + assert player_pattern.json()["author_label"] == "Sam Player" + + +# --------------------------------------------------------------------------- +# Row: "Delete a saved pattern" -- Yes, custom patterns only (coach) / No, +# the delete control never renders (player). Principle: "a player token +# calling a delete ... endpoint gets 403" -- enforced here at the API, not +# only by the UI not rendering the control (see e2e/permissions.spec.ts for +# the DOM-absence half). +# --------------------------------------------------------------------------- + + +def test_delete_a_saved_pattern__coach_yes_player_403(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + + coach_pattern_id = coach.post( + "/api/patterns", + json={"name": "Coach's own", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ).json()["id"] + + # Player attempt is rejected outright, the row survives untouched. + forbidden = player.delete(f"/api/patterns/{coach_pattern_id}") + assert forbidden.status_code == 403 + assert len(coach.get("/api/patterns").json()) == 1 + + # Coach can delete, including a pattern a player authored (README: + # coach delete is not limited to the coach's own tiles). + player_pattern_id = player.post( + "/api/patterns", + json={"name": "Player's own", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ).json()["id"] + coach_delete_own = coach.delete(f"/api/patterns/{coach_pattern_id}") + assert coach_delete_own.status_code == 204 + coach_delete_players = coach.delete(f"/api/patterns/{player_pattern_id}") + assert coach_delete_players.status_code == 204 + assert coach.get("/api/patterns").json() == [] + + +# --------------------------------------------------------------------------- +# Row: "Pattern library, formations, identity" -- Full (coach) / Full, +# view and play (player). Neither role gets more than the other here: both +# get the exact same read-only content, so this asserts identical 200 +# bodies rather than a coach/player diff. +# --------------------------------------------------------------------------- + + +def test_pattern_library_formations_identity__full_view_both_roles(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + + for path in ("/api/library/items", "/api/formations", "/api/identities"): + coach_response = coach.get(path) + player_response = player.get(path) + assert coach_response.status_code == 200, path + assert player_response.status_code == 200, path + assert coach_response.json() == player_response.json(), path + + +# --------------------------------------------------------------------------- +# Row: "Roster" -- Full, plus fit warnings and suggestion review (coach) / +# View-only sliders and work rates with a "view only" label; no fit +# warnings; own row marked "(you)" (player). CRUD 403s a player at the API +# (README: "no create/edit/delete control renders" is a UI statement; +# CLAUDE.md rule 5 requires the same thing be true of the API independent +# of the UI). fit_warnings is asserted ABSENT from the player payload +# (not null, not empty), matching test_roster_routes.py's own proof. +# --------------------------------------------------------------------------- + + +def test_roster__coach_full_with_fit_warnings_player_view_only_no_fit_warnings( + client: TestClient, +) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + + # Coach: full CRUD. + created = coach.post("/api/roster/players", json=_player_body(name="Jordan T.")) + assert created.status_code == 201 + player_id = created.json()["id"] + assert coach.put( + f"/api/roster/players/{player_id}", json=_player_body(name="Jordan Taylor") + ).status_code == 200 + # (Deleted at the very end so both roles' GETs below see the same row.) + + # Coach GET: fit_warnings key present (even empty, no clash seeded here; + # test_roster_routes.py proves it actually fires and reads Bible copy). + coach_body = coach.get("/api/roster").json() + assert "fit_warnings" in coach_body + + # Player: every write is 403, the roster is unchanged by the attempt. + assert player.post("/api/roster/players", json=_player_body(name="Forged")).status_code == 403 + assert player.put( + f"/api/roster/players/{player_id}", json=_player_body(name="Forged edit") + ).status_code == 403 + assert player.delete(f"/api/roster/players/{player_id}").status_code == 403 + assert coach.get("/api/roster").json()["players"][0]["name"] == "Jordan Taylor" + + # Player GET: fit_warnings key entirely absent (not None, not []) -- + # coach-only data never renders in a player-role payload, CLAUDE.md + # rule 5, enforced by the response shape itself, not client-side. + player_body = player.get("/api/roster").json() + assert "fit_warnings" not in player_body + for row in player_body["players"]: + assert "fit_warnings" not in row + + # Own-row marking ("(you)" tag data): is_you is a real field on both + # roles' payloads (the UI-only "view only" slider label and the tag + # text itself are asserted in e2e/permissions.spec.ts). + assert all("is_you" in row for row in player_body["players"]) + + coach.delete(f"/api/roster/players/{player_id}") + + +# --------------------------------------------------------------------------- +# Row: "Suggest own playstyle" -- not applicable (coach) / free text on own +# profile then "pending coach review"; coach sees a gold badge and an +# Approve / Dismiss card (player). No route exists yet: PlaystyleSuggestion +# is a model only (app/models/roster.py), no router is registered in +# app/main.py. T-041 (suggestion flow) is being built in parallel in +# another worktree and owns turning this into a real assertion. +# --------------------------------------------------------------------------- + + +@pytest.mark.skip( + reason=( + "No suggestion route exists in this worktree yet (Brief step 22 / " + "T-041, building in parallel). PlaystyleSuggestion is a model only; " + "app/main.py registers no router for it. T-041 must replace this " + "skip with a real submit/pending/approve/dismiss assertion." + ) +) +def test_suggest_own_playstyle__player_submits_pending_coach_approves_or_dismisses( + client: TestClient, +) -> None: # pragma: no cover - intentionally not runnable yet, see skip reason + raise AssertionError("T-041 must implement this row's route and this test") + + +# --------------------------------------------------------------------------- +# Row: "Sessions" -- create, edit drafts, send, see per-player read +# receipts (coach) / sees sent sessions only, read-only, Watch deep-link, +# Mark as watched feeding the coach's receipt counter (player). No route +# exists yet: TrainingSession/SessionItem/SessionReceipt are models only +# (app/models/sessions.py); app/main.py registers no sessions router. +# That module's own docstring assigns enforcement to T-042. Same treatment +# as the suggestion row above: named and skipped, not silently omitted. +# --------------------------------------------------------------------------- + + +@pytest.mark.skip( + reason=( + "No sessions route exists in this worktree yet (Brief step 23 / " + "T-042). TrainingSession/SessionItem/SessionReceipt are models " + "only; app/main.py registers no router for them. T-042 must " + "replace this skip with a real create/send/receipt assertion, " + "including: receipts created for every recipient at send with " + "viewed_at null, and receipt data absent from player payloads." + ) +) +def test_sessions__coach_creates_sends_sees_receipts_player_reads_and_marks_watched( + client: TestClient, +) -> None: # pragma: no cover - intentionally not runnable yet, see skip reason + raise AssertionError("T-042 must implement this row's route and this test") + + +# --------------------------------------------------------------------------- +# Principles (binding, stated directly under the table): players are +# additive-only; a player token calling a delete endpoint gets 403 across +# EVERY delete-capable route that exists today (a single sweep, rather than +# re-deriving one 403 per route above, to pin the principle itself). +# --------------------------------------------------------------------------- + + +def test_player_token_calling_any_delete_endpoint_gets_403(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + + pattern_id = coach.post( + "/api/patterns", + json={"name": "Protected", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ).json()["id"] + player_id = coach.post("/api/roster/players", json=_player_body()).json()["id"] + + delete_attempts = { + f"/api/patterns/{pattern_id}": player.delete(f"/api/patterns/{pattern_id}"), + f"/api/roster/players/{player_id}": player.delete(f"/api/roster/players/{player_id}"), + } + for path, response in delete_attempts.items(): + assert response.status_code == 403, path + + # Nothing was actually deleted by the rejected attempts. + assert len(coach.get("/api/patterns").json()) == 1 + assert len(coach.get("/api/roster").json()["players"]) == 1 + + +def test_players_are_additive_only__every_player_write_route_is_a_create( + client: TestClient, +) -> None: + """Sweeps every route a player CAN reach and confirms none of them are + edits or deletes of someone else's content: POST /api/boards/current + upserts the team's own single shared board (both roles may edit it, + per the whiteboard row above, by design), POST /api/patterns always + creates a new row stamped to the caller, and no other write route is + reachable by a player at all (roster CRUD is 403, pattern delete is + 403, per the tests above). This is the "additive-only" principle + pinned as one assertion rather than inferred from the others.""" + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com", name="Sam Player") + + before = coach.get("/api/patterns").json() + assert before == [] + + created = player.post( + "/api/patterns", + json={"name": "Additive", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ) + assert created.status_code == 201 # a new row, never an edit of an existing one + + after = coach.get("/api/patterns").json() + assert len(after) == 1 # the player's write ADDED a row, nothing was replaced/removed + + +# --------------------------------------------------------------------------- +# Known ambiguity (T-040 instruction: do not change current behavior, pin +# and report it): join codes. The design README/Brief section 3 table does +# not list join codes as coach-only, but the UI (TeamMeta.tsx) only shows +# the join-code block to a coach. The API was never told to withhold it: +# TeamOut.join_code is returned to any team member by both GET +# /api/teams/current and GET /api/auth/me. docs/agent/STATE.md's open +# founder questions list already flags this ("Confirm before T-040 locks +# the pattern"). This test PINS the current, unchanged behavior so a +# future change is a deliberate, visible diff here, not an accidental one. +# --------------------------------------------------------------------------- + + +def test_join_code_is_returned_to_a_player_by_the_api_ambiguity_pinned_not_enforced( + client: TestClient, +) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + + join_code = coach.get("/api/teams/current").json()["join_code"] + assert len(join_code) == 6 + + # Current (unchanged) behavior: a player's own /api/teams/current + # response also carries join_code. The UI is the only layer that + # withholds it from a player today (TeamMeta.tsx renders the block + # only when role_on_team == "coach"). See docs/agent/STATE.md open + # founder question 2 and this ticket's final report. + player_team = player.get("/api/teams/current").json() + assert player_team["join_code"] == join_code + + player_me = player.get("/api/auth/me").json() + assert player_me["memberships"][0]["team"]["join_code"] == join_code diff --git a/docs/agent/BACKLOG.md b/docs/agent/BACKLOG.md index 64cd2a5..7f5e199 100644 --- a/docs/agent/BACKLOG.md +++ b/docs/agent/BACKLOG.md @@ -11,17 +11,19 @@ Model: sonnet default; opus = hard ticket, never downgrade. | T-004 | Scoped query layer + full schema from doc 03 + Alembic chain from zero + cross-team read test returns nothing | 4, 5 | platform | sonnet | T-001 | T-002 | done | | T-010 | Seed files: transcribe Bible per doc 03 §4-6 (12 patterns, 8 deliveries, 3 rotations, 6 formations+keystones, rondo 5 zones, 6 archetypes+pass-risk, 4 animated + 2 static ref teams, detail-only slots, cult corner, roles, synergies) | 6 | content-seeder | sonnet | T-004 | T-020 | done | | T-011 | Em-dash transform pass + CI copy scan + seed validator (required fields, blurb ≤25 words, banned identity phrases, slot refs resolve) | 7, 8 | content-seeder | sonnet | T-010 | T-020 | done | +| T-012 | Founder decision 2026-07-16: identities age_hint column (amend doc 03, Alembic migration after T-041's, backfill from Bible 8.2.4, validator + seed update) | founder | content-seeder | sonnet | T-010, T-041 | T-043 | todo | | T-020 | Board core: pitch canvas, landscape model coords, token drag 60fps @23 tokens, portrait mapping (left=y, top=100-x) with lossless round-trip unit test FIRST | 9, 10 | board-engineer | opus | T-001 | T-010 | done | | T-021 | Lane graph: suggested/confirmed/blocked states, two independent thresholds, live recompute during drag, interception dot | 11, 12 | board-engineer | opus | T-020 | T-011 | done | | T-022 | Zones + animation player (declarative specs AND raw keyframes, ball waypoints chase bound player) + recorder (all tokens incl. opponents + ball) | 13, 14, 15 | board-engineer | opus | T-021 | none | done | | T-030 | Whiteboard page (PNG 01-05, 14, 34): toolbar, view menu, record/save into My Patterns | 16 | screens | sonnet | T-022, T-004 | T-031 | done | | T-031 | Patterns page (PNG 05-10, 29-31, 15-18, 35): sheet w/ 3 libraries, chips, search, meta bar, details panels | 17 | screens | sonnet | T-022, T-011 | T-030 | done | -| T-032 | Formations page (PNG 11, 19, 37-39, 43) + keystone pulse/keycards + Rondo Map (PNG 32, 36) | 18 | screens | sonnet | T-031 | T-033 | doing | +| T-032 | Formations page (PNG 11, 19, 37-39, 43) + keystone pulse/keycards + Rondo Map (PNG 32, 36) | 18 | screens | sonnet | T-031 | T-033 | done | | T-033 | Roster page (PNG 12, 20): CRUD, chips, 6 sliders, double-exposure warning coach-only | 19 | screens | sonnet | T-004, T-011 | T-032 | done | -| T-034 | Identity page (PNG 13, 33, 40-42, 44, 45): 4 scripted animations, 2 static shapes, detail slots, pass-risk, cult corner | 20 | screens | sonnet | T-031 | T-033 | doing | -| T-040 | Role gating UI + API 403 enforcement, permission test suite both roles (Brief §3 table, every row) | 21 | collab | sonnet | T-030..T-034 | T-041 | todo | -| T-041 | Playstyle suggestion flow (PNG 24, 25, 27) | 22 | collab | sonnet | T-033 | T-040 | todo | +| T-034 | Identity page (PNG 13, 33, 40-42, 44, 45): 4 scripted animations, 2 static shapes, detail slots, pass-risk, cult corner | 20 | screens | sonnet | T-031 | T-033 | done | +| T-040 | Role gating UI + API 403 enforcement, permission test suite both roles (Brief §3 table, every row) | 21 | collab | sonnet | T-030..T-034 | T-041 | doing | +| T-041 | Playstyle suggestion flow (PNG 24, 25, 27) | 22 | collab | sonnet | T-033 | T-040 | doing | | T-042 | Sessions: draft builder + picker w/ thumbnails, send, receipts, player view w/ Watch deep-link + Mark as watched (PNG 21-23, 26, 28) | 23 | collab | sonnet | T-031, T-040 | none | todo | +| T-043 | Founder decision 2026-07-16: role-scoped join codes (player + coach code, migration), join codes coach-only in API payloads, head coach (creator) removes members + edits member roles, permission tests | founder | platform | sonnet | T-003, T-040 | T-041 | doing | | T-050 | Phone pass: icon rail, stacked grids, portrait boards all surfaces, cross-device save/replay test | 24 | screens | sonnet | T-030..T-042 | none | todo | | T-051 | Hardening: full em-dash sweep, permission suite in CI, demo-path e2e (Brief §6 narrative as one Playwright journey, both viewports) | 25 | verifier | sonnet | T-050 | none | todo | | T-060 | Deploy: Render service (persistent volume), Litestream to object storage, env config, prod Turso decision point, smoke journey vs prod URL | doc 04 §2 | platform | sonnet | T-051 | none | todo | diff --git a/e2e/permissions.spec.ts b/e2e/permissions.spec.ts new file mode 100644 index 0000000..c1033a0 --- /dev/null +++ b/e2e/permissions.spec.ts @@ -0,0 +1,222 @@ +// Permission gating journey (T-040, Brief step 21). Runs under both +// Playwright projects (mobile iPhone 13 portrait, desktop landscape) per +// playwright.config.ts, so every assertion below is proven on both +// viewports without any viewport-specific code path. +// +// This file is the UI half of the Brief section 3 permission table audit; +// backend/tests/test_permissions.py is the API half, one test per row. +// Per the verify-ui skill's role-check contract: "if the surface differs +// by role, journey runs both roles; assert coach-only elements are ABSENT +// from the DOM for players (not hidden)." Every assertion here uses +// toHaveCount(0), never toBeHidden()/not.toBeVisible(), for exactly that +// reason: a disabled-but-present control would fail these checks the same +// as an absent one, but a hidden-by-CSS control would wrongly pass them. +// +// Coverage against the table (row -> what this file proves for the PLAYER +// role; the coach-role positive case for each of these already has its own +// full journey in whiteboard.spec.ts / roster.spec.ts and is not repeated +// here to avoid duplicating those journeys' maintenance surface): +// - Delete a saved pattern: absent on Whiteboard for a player, even for +// a pattern the player authored themselves. +// - Roster: fit-warning banner, Add player, Edit, Delete all absent for +// a player; "(view only)" tag present on sliders/work rates. +// - Pattern library, formations, identity: a player can reach and use +// the same view/play surfaces a coach can (no row denies this), swept +// across all three of those pages in one pass. +// - Ambient coach-only chrome (the join-code block in the topbar) is +// absent for a player on every one of the five pages, not just the +// one it happens to be tested on elsewhere (auth-teams.spec.ts checks +// it once, right after joining; this file checks it does not reappear +// on navigation to any other page). + +import { test, expect, assertCleanPage, registerCoach, registerPlayer } from "./fixtures"; +import type { Page } from "@playwright/test"; + +// Same reasoning as roster.spec.ts's own robustClick: the mobile project's +// touch+keyboard emulation shrinks the visual viewport once a text input +// is focused and never restores it, which can make a plain coordinate +// click land on the wrong element once the page has scrolled. Dispatching +// the event directly targets the element with no coordinate math. +async function robustClick(page: Page, testId: string) { + const locator = page.getByTestId(testId); + await locator.scrollIntoViewIfNeeded(); + await locator.dispatchEvent("click"); +} + +async function goToPage(page: Page, key: "whiteboard" | "patterns" | "roster" | "formations" | "identity") { + await robustClick(page, `nav-${key}`); + await expect(page.getByTestId(`nav-${key}`)).toHaveAttribute("aria-current", "page"); +} + +// Same model->client mapping whiteboard.spec.ts/patterns.spec.ts use (the +// VB constants those files hardcode cancel out algebraically into plain +// x/100, y/100 fractions of the board's own bounding box once portrait's +// axis swap is applied, so this drops the unused constants and keeps only +// the fractions that actually matter). +async function dragTokenTo(page: Page, id: string, m: { x: number; y: number }) { + const orientation = await page.locator(".board-wrap").first().getAttribute("data-orientation"); + const box = (await page.getByTestId("board").boundingBox())!; + const fx = orientation === "portrait" ? m.y / 100 : m.x / 100; + const fy = orientation === "portrait" ? (100 - m.x) / 100 : m.y / 100; + const target = { x: box.x + fx * box.width, y: box.y + fy * box.height }; + + const b = (await page.locator(`[data-token-id="${id}"]`).boundingBox())!; + const start = { x: b.x + b.width / 2, y: b.y + b.height / 2 }; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(target.x, target.y, { steps: 10 }); + await page.mouse.up(); +} + +/** Every selector below is a control or data block the Brief section 3 + * table marks coach-only somewhere in the app. Asserted absent, as a full + * sweep, on whichever page is currently active in `page`. Most of these + * selectors only ever render on one particular page (e.g. fit-warning + * only on Roster), so most of these counts are trivially zero on the other + * four pages; that is the point, this is a blanket sweep proving none of + * them leak onto a page they were not designed for either. */ +async function assertNoCoachOnlyChrome(page: Page) { + await expect(page.locator(".join-code")).toHaveCount(0); + await expect(page.locator(".fit-warning")).toHaveCount(0); + await expect(page.getByTestId("roster-add-player")).toHaveCount(0); + await expect(page.getByTestId("player-edit")).toHaveCount(0); + await expect(page.getByTestId("player-delete")).toHaveCount(0); + await expect(page.locator('[data-testid^="delete-pattern-"]')).toHaveCount(0); + await expect(page.getByRole("button", { name: "Delete" })).toHaveCount(0); +} + +test.describe("permissions: player-role visit to every page, coach-only controls and data absent", () => { + test("whiteboard, patterns, roster, formations, identity", async ({ browser }) => { + // --- Coach seeds content a player should be able to VIEW but never + // edit/delete: a saved whiteboard pattern, and a double-exposure + // roster pair so the fit-warning banner has something to (not) show --- + const coachContext = await browser.newContext(); + const coachPage = await coachContext.newPage(); + const { joinCode } = await registerCoach(coachPage, { displayName: "Coach Perm Test" }); + + await coachPage.getByTestId("record").click(); + await dragTokenTo(coachPage, "home-4", { x: 60, y: 40 }); + await coachPage.getByTestId("stop-record").click(); + await coachPage.getByTestId("record-name").fill("Coach seeded pattern"); + await coachPage.getByTestId("save-pattern").click(); + await expect( + coachPage.getByTestId("saved-pattern").filter({ hasText: "Coach seeded pattern" }) + ).toHaveCount(1); + + await goToPage(coachPage, "roster"); + async function addPlayerRow(opts: { + name: string; + roleCode: string; + flank: string; + awr: string; + dwr: string; + }) { + await robustClick(coachPage, "roster-add-player"); + await coachPage.getByTestId("player-name").fill(opts.name); + await coachPage.getByTestId("player-role").selectOption(opts.roleCode); + await coachPage.getByTestId("player-flank").selectOption(opts.flank); + await coachPage.getByTestId("player-awr").selectOption(opts.awr); + await coachPage.getByTestId("player-dwr").selectOption(opts.dwr); + await robustClick(coachPage, "player-save"); + await expect(coachPage.getByTestId("player-save")).toHaveCount(0); + } + await addPlayerRow({ name: "Wide Winger", roleCode: "touchline_winger", flank: "right", awr: "high", dwr: "low" }); + await addPlayerRow({ name: "Back Runner", roleCode: "overlapping_fb", flank: "right", awr: "high", dwr: "med" }); + await expect(coachPage.getByTestId("fit-warning-right")).toBeVisible(); + + // --- Player joins the same team --- + const playerContext = await browser.newContext(); + const playerPage = await playerContext.newPage(); + const issues = { consoleErrors: [] as string[], failedRequests: [] as string[], serverErrors: [] as string[] }; + playerPage.on("console", (m) => m.type() === "error" && issues.consoleErrors.push(m.text())); + playerPage.on("requestfailed", (r) => issues.failedRequests.push(`${r.method()} ${r.url()}`)); + playerPage.on("response", (r) => r.status() >= 500 && issues.serverErrors.push(`${r.status()} ${r.url()}`)); + await registerPlayer(playerPage, joinCode, { displayName: "Player Perm Test" }); + + // --------------------------------------------------------------- + // Whiteboard + // --------------------------------------------------------------- + // registerPlayer already lands on the Whiteboard (its own board is the + // sign-in destination), so no extra nav click is needed for this page. + await expect(playerPage.getByText("(player)")).toBeVisible(); + const coachTile = playerPage.getByTestId("saved-pattern").filter({ hasText: "Coach seeded pattern" }); + await expect(coachTile).toHaveCount(1); // viewable (README: view + play) + await expect(coachTile.getByTestId("saved-pattern-author")).toHaveText("COACH"); + await assertNoCoachOnlyChrome(playerPage); + + // A player CAN record and save their own pattern (additive-only), and + // even that pattern's own tile never grows a delete control. + await playerPage.getByTestId("record").click(); + await dragTokenTo(playerPage, "home-6", { x: 44, y: 44 }); + await playerPage.getByTestId("stop-record").click(); + await playerPage.getByTestId("record-name").fill("Player own pattern"); + await playerPage.getByTestId("save-pattern").click(); + const ownTile = playerPage.getByTestId("saved-pattern").filter({ hasText: "Player own pattern" }); + await expect(ownTile).toHaveCount(1); + await expect(ownTile.getByTestId("saved-pattern-author")).toHaveText("Player Perm Test"); + await assertNoCoachOnlyChrome(playerPage); // still none, even on the player's OWN tile + + // --------------------------------------------------------------- + // Patterns (Brief section 3: "Full (view and play)" for both roles) + // --------------------------------------------------------------- + await goToPage(playerPage, "patterns"); + await assertNoCoachOnlyChrome(playerPage); + const handle = playerPage.getByTestId("patterns-sheet-handle"); + if ((await handle.getAttribute("aria-expanded")) !== "true") { + await robustClick(playerPage, "patterns-sheet-handle"); + } + await expect(playerPage.getByTestId("patterns-sheet-body")).toBeVisible(); + // The player's own recorded pattern from the whiteboard step above is + // reachable via the "My patterns" chip, proving view + play, not just + // the coach-authored presets. + await robustClick(playerPage, "patterns-chip-mine"); + await expect( + playerPage.getByTestId("patterns-tile").filter({ hasText: "Player own pattern" }) + ).toHaveCount(1); + + // --------------------------------------------------------------- + // Roster (Brief section 3: fit warnings coach-only, view-only for a + // player, own row marked "(you)", no add/edit/delete control) + // --------------------------------------------------------------- + await goToPage(playerPage, "roster"); + await assertNoCoachOnlyChrome(playerPage); + await expect(playerPage.getByTestId(/roster-row-\d+/)).toHaveCount(2); + const rosterRow = playerPage.getByTestId(/roster-row-\d+/).filter({ hasText: "Wide Winger" }); + await rosterRow.scrollIntoViewIfNeeded(); + await rosterRow.dispatchEvent("click"); + // Whichever row this is, its detail panel must show the view-only tag + // and never an edit/delete action (already swept by + // assertNoCoachOnlyChrome above, re-asserted after selecting a row + // since the detail panel only mounts once a row is selected). + await expect(playerPage.getByTestId("roster-detail")).toContainText("(view only)"); + await assertNoCoachOnlyChrome(playerPage); + + // --------------------------------------------------------------- + // Formations (Brief section 3: "Full (view and play)" for both roles) + // --------------------------------------------------------------- + await goToPage(playerPage, "formations"); + await assertNoCoachOnlyChrome(playerPage); + await expect(playerPage.getByTestId("formations-meta-bar")).toContainText("4-3-3"); + // A keystone tap shows its keycard for a player exactly as it does for + // a coach: full view + play, nothing gated on this page. + await playerPage.locator('[data-token-id="six"]').click(); + await expect(playerPage.getByTestId("formations-keycard")).toBeVisible(); + + // --------------------------------------------------------------- + // Identity (Brief section 3: "Full (view and play)" for both roles) + // --------------------------------------------------------------- + await goToPage(playerPage, "identity"); + await assertNoCoachOnlyChrome(playerPage); + const idHandle = playerPage.getByTestId("identity-sheet-handle"); + if ((await idHandle.getAttribute("aria-expanded")) !== "true") { + await robustClick(playerPage, "identity-sheet-handle"); + } + await expect(playerPage.getByTestId("identity-sheet-body")).toBeVisible(); + await robustClick(playerPage, "identity-sheet-handle"); + + await assertCleanPage(playerPage, issues); + + await coachContext.close(); + await playerContext.close(); + }); +});