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.
84 lines
2.9 KiB
TypeScript
84 lines
2.9 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|