From efddb1c7c72f09025bd13c2db4d5bd274e4c0508 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 15:12:52 +0500 Subject: [PATCH] =?UTF-8?q?fix(site-finder):=20=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D1=8C=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=20?= =?UTF-8?q?=D0=BA=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F=D0=BC=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=81=D0=BE=D0=B2=20=E2=80=94=20=D0=B2=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B5=D1=86=20=D0=B8=D0=B7=20=D1=81=D0=B5?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D0=B8=20(#2782)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRUD именованных профилей POI-весов был недостижим никому без DevTools. Панель отпирала его значением `admin_token` из localStorage, а записать это значение было нечем: инпут удалили в #442. Поле «User ID» тоже не показывалось — оно само стояло за `{!!adminToken && ...}`. Признак, за которым была заперта фича, ничего не решал. Сервер перестал читать `X-Admin-Token` в #437, последний `verify_admin_token` удалён в #2775. Проверено живым запросом на проде: GET weight-profiles с корректным токеном, с мусорным и без заголовка вовсе дают один и тот же 200. Настоящая защита — два других слоя, тоже прод-проверенных: Caddy basic_auth (без кред 401 на периметре, клиентский X-Authenticated-User не проходит) и rbac_guard (`role != admin` → 403 admin only для pilot и analyst). Поэтому мёртвый механизм убран целиком, а не наполовину: гейт `!!adminToken`, оба чтения localStorage, отправка заголовка во всех четырёх хуках и пропсы `userId`/`adminToken` панели. Владельца профилей панель берёт из `/api/v1/me` — там же, где его берут RouteGuard и Topbar (тот же queryKey, лишнего запроса нет). Профили и так per-user, а единственный сохранённый на проде профиль лежит под `user_id='admin'` — ровно под именем basic_auth-аккаунта, так что миграции нет. Заодно чинится вторая оборванная проводка на том же пути. `analyze` отправлял `profile_id` без `profile_user_id`, а `resolve_weights()` ищет профиль как `get_profile(db, user_id, profile_id)` — при `user_id=None` веса молча падали на системные, при этом ответ рапортовал `weights_profile.source = "profile"`. Проверено на проде: `profile_id=1` без `profile_user_id` → `tram_stop −0.5` вместо `−0.4` из профиля. Без этой правки выбор профиля в разблокированном дропдауне показывал бы одни ползунки, а score считался бы по другим весам. Ветка вынесена в `buildAnalyzeOptions()` — она дублировалась в двух местах. Микрокопия: «Укажите User ID и Admin Token для сохранения профилей» обещала поле, которого нет. Текст называет причину, а не действие, и в проде вообще недостижим — без входа страницу не отдаёт Caddy. Refs #2782 --- frontend/src/app/legacy/site-finder/page.tsx | 78 +++------ .../site-finder/WeightProfilePanel.tsx | 54 +++---- .../WeightProfilePanel.identity.test.tsx | 151 ++++++++++++++++++ frontend/src/hooks/useSiteAnalysis.ts | 34 +++- frontend/src/lib/api/weightProfiles.ts | 36 ++--- 5 files changed, 238 insertions(+), 115 deletions(-) create mode 100644 frontend/src/components/site-finder/__tests__/WeightProfilePanel.identity.test.tsx diff --git a/frontend/src/app/legacy/site-finder/page.tsx b/frontend/src/app/legacy/site-finder/page.tsx index bd7144c7..61e580a8 100644 --- a/frontend/src/app/legacy/site-finder/page.tsx +++ b/frontend/src/app/legacy/site-finder/page.tsx @@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; import { LandTab } from "@/components/site-finder/LandTab"; import { MarketTab } from "@/components/site-finder/MarketTab"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; -import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; +import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useConnectionPoints } from "@/hooks/useConnectionPoints"; import { useCustomPois } from "@/hooks/useCustomPois"; @@ -22,6 +22,7 @@ import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, } from "@/lib/api/weightProfiles"; +import { useMe } from "@/lib/useMe"; // SiteMap imports Leaflet which requires browser APIs — load without SSR const SiteMap = dynamic( @@ -129,16 +130,16 @@ function SiteFinderContent() { // Ref to skip the initial mount effect (we only re-analyze on actual changes). const weightsChangeInitializedRef = useRef(false); - const [profileUserId, setProfileUserId] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_user_id") ?? ""), - ); - const [adminToken] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); + // Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения + // из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`, + // который сервер не читает с #437) — оба недостижимы без DevTools. + // + // 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без + // `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом + // `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без + // user_id → tram_stop −0.5 вместо −0.4 из профиля). То есть выбранный профиль + // молча не применялся бы, а UI показывал бы его ползунки. + const profileUserId = useMe().data?.username ?? ""; // Lazy init: считаем initialTab один раз на mount (useState всё равно // игнорирует initializer после первого render — не тратим CPU). const [tab, setTabState] = useState(() => { @@ -192,12 +193,7 @@ function SiteFinderContent() { setIsochrones(undefined); mutate({ cad: currentData.cad_num, - options: - profileId != null - ? { profileId } - : currentProfileUserId - ? { profileUserId: currentProfileUserId, weights } - : { weights }, + options: buildAnalyzeOptions(weights, profileId, currentProfileUserId), }); // mutate is stable from useMutation — safe to omit from deps. // data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num @@ -216,12 +212,11 @@ function SiteFinderContent() { // slider values are always respected even without a saved profile (#201). mutate({ cad: cadNum, - options: - activeProfileId != null - ? { profileId: activeProfileId } - : profileUserId - ? { profileUserId, weights: currentWeights } - : { weights: currentWeights }, + options: buildAnalyzeOptions( + currentWeights, + activeProfileId, + profileUserId, + ), }); } @@ -329,46 +324,9 @@ function SiteFinderContent() { {/* Weight profile panel — collapsible, below header */}
- {/* Optional user-id field for profile CRUD (shown only when adminToken present) */} - {!!adminToken && ( -
- - { - setProfileUserId(e.target.value); - if (typeof window !== "undefined") { - localStorage.setItem("admin_user_id", e.target.value); - } - }} - /> -
- )} {/* Recalculation indicator — shown while re-analyze is in-flight after weights change (data already loaded, pendingWeightsChange set). */} diff --git a/frontend/src/components/site-finder/WeightProfilePanel.tsx b/frontend/src/components/site-finder/WeightProfilePanel.tsx index 887cd1d8..60fcbe5b 100644 --- a/frontend/src/components/site-finder/WeightProfilePanel.tsx +++ b/frontend/src/components/site-finder/WeightProfilePanel.tsx @@ -3,6 +3,7 @@ import { useCallback, useState } from "react"; import { SectionLabel } from "@/components/ui/SectionLabel"; +import { useMe } from "@/lib/useMe"; import { POI_CATEGORIES, POI_DEFAULT_WEIGHTS, @@ -29,13 +30,6 @@ interface Props { weights: Record, profileId: number | null, ) => void; - /** - * If provided, enables save/load from DB. - * Must be non-empty for CRUD functionality. - */ - userId?: string; - /** Admin token for CRUD API calls. */ - adminToken?: string; } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -66,12 +60,7 @@ function weightsEqual( // ── Component ───────────────────────────────────────────────────────────────── -export function WeightProfilePanel({ - currentWeights, - onWeightsChange, - userId, - adminToken, -}: Props) { +export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { const [open, setOpen] = useState(false); // Local draft weights — editable before "Применить" @@ -89,11 +78,19 @@ export function WeightProfilePanel({ const [saveName, setSaveName] = useState(""); const [saveDefault, setSaveDefault] = useState(false); - // Profiles query (only when userId + adminToken provided) - const canUseCrud = !!userId && !!adminToken; - const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? ""); + // Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили + // руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было + // взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в + // #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут + // RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется). + // В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто + // бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен. + const { data: me } = useMe(); + const userId = me?.username ?? ""; + const canUseCrud = !!userId; + const profilesQuery = useWeightProfiles(userId); - const createMutation = useCreateProfile(adminToken ?? ""); + const createMutation = useCreateProfile(); // ── Handlers ──────────────────────────────────────────────────────────────── @@ -117,7 +114,7 @@ export function WeightProfilePanel({ } const handleSaveProfile = useCallback(async () => { - if (!canUseCrud || !userId || !adminToken) { + if (!canUseCrud || !userId) { setShowSaveDialog(false); return; } @@ -136,15 +133,7 @@ export function WeightProfilePanel({ } catch { // Error visible through createMutation.error } - }, [ - canUseCrud, - userId, - adminToken, - saveName, - draft, - saveDefault, - createMutation, - ]); + }, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]); // ── Derived ───────────────────────────────────────────────────────────────── @@ -242,6 +231,7 @@ export function WeightProfilePanel({
@@ -278,10 +268,13 @@ export function WeightProfilePanel({
)} - {/* Hint when no crud */} + {/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт + Caddy basic_auth) — остаётся для dev-запуска без прокси, где + /api/v1/me отвечает 401. Текст называет причину, а не действие: + вводить тут больше нечего. */} {!canUseCrud && (

- Укажите User ID и Admin Token для сохранения профилей. + Пользователь не определён — сохранение профилей недоступно.

)} @@ -362,6 +355,9 @@ export function WeightProfilePanel({ }} > Новый профиль +

+ Сохранится для пользователя {userId} — другие его не увидят. +

(); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +const PROFILE = { + id: 1, + user_id: "admin", + profile_name: "Мой профиль", + weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 }, + is_default: true, + description: null, + created_at: "2026-05-15T05:30:51Z", + updated_at: "2026-05-15T05:30:51Z", +}; + +function renderPanel() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + {}} + /> + , + ); +} + +/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */ +async function openPanel() { + const { default: userEvent } = await import("@testing-library/user-event"); + await userEvent.setup().click(screen.getByText("POI Веса")); +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/api/v1/me")) { + return jsonResponse({ + username: "admin", + role: "admin", + allowed_paths: ["/**"], + deny_paths: [], + }); + } + if (url.includes("/weight-profiles")) { + return jsonResponse([PROFILE]); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => { + it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => { + renderPanel(); + await openPanel(); + + // Профили грузятся по user_id из /me, без ручного ввода и без токена. + await waitFor(() => + expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(), + ); + expect(screen.getByText("Профиль:")).toBeTruthy(); + }); + + it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => { + renderPanel(); + await openPanel(); + + await waitFor(() => + expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(), + ); + expect(screen.queryByText(/Admin Token/i)).toBeNull(); + expect(screen.queryByText(/Укажите User ID/i)).toBeNull(); + }); + + it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => { + renderPanel(); + await openPanel(); + + await waitFor(() => { + const crudCall = fetchMock.mock.calls.find(([input]) => + String(input).includes("/weight-profiles"), + ); + expect(crudCall).toBeTruthy(); + const headers = new Headers( + (crudCall?.[1] as RequestInit | undefined)?.headers, + ); + expect(headers.has("X-Admin-Token")).toBe(false); + }); + }); +}); + +describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => { + const weights = { ...POI_DEFAULT_WEIGHTS }; + + it("выбранный профиль уходит вместе с profileUserId", () => { + expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({ + profileId: 7, + profileUserId: "admin", + }); + }); + + it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => { + const options = buildAnalyzeOptions(weights, 7, ""); + expect(options.profileId).toBeUndefined(); + expect(options.weights).toEqual(weights); + }); + + it("без профиля уходят inline-веса и владелец для default-профиля", () => { + expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({ + profileUserId: "admin", + weights, + }); + }); +}); diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 1ffd3a7e..745ccdb1 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder"; // /fetch-status every 2s, re-triggers analyze когда status=ready. export type FetchStatus = - | "ready" - | "fetching" - | "failed" - | "not_in_nspd" - | "invalid_format"; + "ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format"; export interface FetchStatusResponse { status: FetchStatus; @@ -52,6 +48,34 @@ export interface AnalyzeOptions { weights?: Record | null; } +/** + * Собрать options для POST /analyze из состояния панели весов POI. + * + * 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не + * стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id, + * profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is + * not None` не выполняется, и веса тихо падают на системные. Ответ при этом + * рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде + * #2782: `profile_id=1` без `profile_user_id` → `tram_stop −0.5` вместо `−0.4` из + * профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам. + * + * Когда пользователь неизвестен (dev без Caddy: /api/v1/me → 401) — шлём inline + * `weights`. Они всегда равны ползункам панели, так что расхождения нет. + */ +export function buildAnalyzeOptions( + weights: Record, + profileId: number | null, + profileUserId: string, +): AnalyzeOptions { + if (profileId != null && profileUserId) { + return { profileId, profileUserId }; + } + if (profileUserId) { + return { profileUserId, weights }; + } + return { weights }; +} + /** * Custom hook для analyze flow с graceful on-demand fetch fallback. * diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts index f2619dfa..4aa28bd7 100644 --- a/frontend/src/lib/api/weightProfiles.ts +++ b/frontend/src/lib/api/weightProfiles.ts @@ -91,35 +91,37 @@ export const POI_WEIGHT_MAX = 3; const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles"; -function profilesHeaders(adminToken: string): HeadersInit { - return { "X-Admin-Token": adminToken }; -} +// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний +// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и +// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200 +// с корректным токеном, с мусорным и без заголовка вовсе. +// +// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены: +// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре +// (подставленный клиентом X-Authenticated-User туда же не проходит); +// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only". // ── Hooks ───────────────────────────────────────────────────────────────────── /** List all weight profiles for a given user_id. */ -export function useWeightProfiles(userId: string, adminToken: string) { +export function useWeightProfiles(userId: string) { return useQuery({ queryKey: ["weight-profiles", userId], queryFn: () => apiFetch( `${BASE_PATH}?user_id=${encodeURIComponent(userId)}`, - { - headers: profilesHeaders(adminToken), - }, ), - enabled: !!userId && !!adminToken, + enabled: !!userId, }); } /** Create a new weight profile. Invalidates the list query on success. */ -export function useCreateProfile(adminToken: string) { +export function useCreateProfile() { const qc = useQueryClient(); return useMutation({ mutationFn: (payload) => apiFetch(BASE_PATH, { method: "POST", - headers: profilesHeaders(adminToken), body: JSON.stringify(payload), }), onSuccess: (_, variables) => { @@ -131,11 +133,7 @@ export function useCreateProfile(adminToken: string) { } /** Update an existing weight profile by id. */ -export function useUpdateProfile( - userId: string, - profileId: number, - adminToken: string, -) { +export function useUpdateProfile(userId: string, profileId: number) { const qc = useQueryClient(); return useMutation({ mutationFn: (payload) => @@ -143,7 +141,6 @@ export function useUpdateProfile( `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, { method: "PUT", - headers: profilesHeaders(adminToken), body: JSON.stringify(payload), }, ), @@ -154,16 +151,13 @@ export function useUpdateProfile( } /** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */ -export function useDeleteProfile(userId: string, adminToken: string) { +export function useDeleteProfile(userId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (profileId) => apiFetch( `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { - method: "DELETE", - headers: profilesHeaders(adminToken), - }, + { method: "DELETE" }, ), onSuccess: () => { void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });