gendesign/frontend/src/lib/__tests__/isPathAllowed.test.ts
Light1YT 4a4a6e8727 feat(rbac): сузить pilot scope только до /trade-in/**
Decision 2026-05-26: pilot аккаунты пилот-программы получают доступ
ТОЛЬКО к разделу Trade-In (оценка вторички). Landing, Analytics,
Site Finder, Concept — admin-only.

Изменения:
- `auth/roles.yaml` — `pilot.paths` сужен с 7 путей до 2:
  `/trade-in/**` + `/trade-in/api/v1/**`. Deny список оставлен
  как есть для defense-in-depth.
- `backend/tests/test_rbac.py` + tradein mirror — переписан
  `test_is_path_allowed_pilot_excludes_admin` → `_pilot_only_tradein`:
  pilot allowed только /trade-in, denied на /, /analytics, /site-finder,
  /concept, /api/v1/parcels. Тест `test_get_user_scope_pilot` ассертит
  что /trade-in/** в allowed, / и /analytics/** нет.
- `frontend/src/lib/__tests__/isPathAllowed.test.ts` — те же parity
  обновления (pilot allowed list сужен, denied list расширен landing
  + non-tradein путями).
- `caddy/users.caddy.snippet` — синхронизирован с VPS-актуальной
  версией: regen'd user1..user10 hashes + admintest/pilottest entries
  (locally added на VPS 2026-05-26 для QA RBAC). Без этого `deploy.yml`
  делал бы `git reset --hard` и стирал бы локальные правки.
- `auth/roles.yaml` users: блок — также добавлены `admintest: admin`
  и `pilottest: pilot` (mirror VPS local edit).

Эффект для UX:
- pilot открывает `/` → frontend RouteGuard → fullscreen «Доступа нет»
  (т.к. `/` не в allowed_paths).
- pilot TopNav на main frontend — только пункт «Trade-In» виден
  (остальные `/analytics`, `/site-finder`, `/concept`, `/admin`
  отфильтрованы `isPathAllowed`).
- pilot открывает `/trade-in` → работает как раньше.
- admin (admintest, kopylov-если бы был admin) — без изменений.

Backend middleware path-enforcement не делает — security guarantee
по-прежнему на admin-only endpoints (`/api/v1/admin/*` блочится для
пилота). Path-filter на UI-уровне через `allowed_paths` /
`deny_paths` в /me response.

Pilot login flow: после basic_auth → main frontend → useMe → /me →
scope (allowed=/trade-in/**). RouteGuard на `/` → NoAccessScreen.
Pilot должен bookmark'ить `https://gendsgn.ru/trade-in/` напрямую.

Follow-up: можно добавить auto-redirect в RouteGuard — если pilot
landing-path → redirect на первый allowed (`/trade-in`). Отдельный PR.

Tests: 20/20 pass на обоих backend стэках, ruff clean.
2026-05-26 12:40:08 +05:00

84 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Parity-tests с backend `tests/test_rbac.py::test_is_path_allowed_*`.
*
* Если кто-то меняет глоб-семантику backend'а — должен синхронно поменять JS
* порт здесь, иначе UI начнёт врать про доступы (показывать ссылки, которые
* backend заблокирует, или наоборот скрывать разрешённые).
*
* Pairs мирорят `auth/roles.yaml` (обновлено 2026-05-26 — pilot scope сужен
* до /trade-in/** only):
* admin: paths=["/**"] deny=[]
* pilot: paths=["/trade-in/**", "/trade-in/api/v1/**"]
* deny=["/admin/**", "/api/v1/admin/**", "/trade-in/api/v1/admin/**"]
*/
import { isPathAllowed } from "../isPathAllowed";
const ADMIN_PATHS = ["/**"] as const;
const ADMIN_DENY = [] as const;
const PILOT_PATHS = ["/trade-in/**", "/trade-in/api/v1/**"] as const;
const PILOT_DENY = [
"/admin/**",
"/api/v1/admin/**",
"/trade-in/api/v1/admin/**",
] as const;
describe("isPathAllowed — admin everywhere", () => {
const cases: string[] = [
"/",
"/admin",
"/admin/scrape/runs",
"/api/v1/admin/scrape/status",
"/trade-in/api/v1/admin/scrape",
"/concept/123",
"/analytics/dashboard",
];
it.each(cases)("allows %s", (path) => {
expect(isPathAllowed(ADMIN_PATHS, ADMIN_DENY, path)).toBe(true);
});
});
describe("isPathAllowed — pilot allowed paths (только trade-in)", () => {
const allowed: string[] = [
"/trade-in",
"/trade-in/",
"/trade-in/123",
"/trade-in/api/v1/search",
"/trade-in/api/v1/me",
];
it.each(allowed)("allows %s", (path) => {
expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, path)).toBe(true);
});
});
describe("isPathAllowed — pilot denied paths (landing + admin + остальное)", () => {
const denied: string[] = [
// Landing + non-tradein разделы — теперь denied (decision 2026-05-26)
"/",
"/analytics/dashboard",
"/site-finder/123",
"/concept/abc",
"/api/v1/parcels/123",
"/api/v1/analytics/dashboard",
// Admin paths (explicit deny + not in allowed)
"/admin",
"/admin/jobs",
"/admin/scrape/runs/42",
"/api/v1/admin/scrape/status",
"/api/v1/admin/jobs",
"/trade-in/api/v1/admin/scrape",
];
it.each(denied)("denies %s", (path) => {
expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, path)).toBe(false);
});
});
describe("isPathAllowed — edge cases", () => {
it("denies path not matching any rule", () => {
expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, "/unknown")).toBe(false);
});
it("matches bare /trade-in via /trade-in/** glob (no trailing /)", () => {
// Backend glob `/foo/**` после подстановки `(?:/.*)?` матчит и bare `/foo`.
expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, "/trade-in")).toBe(true);
});
});