Реализованы задачи бэклога 1-5 (1я не до конца)

This commit is contained in:
mi
2026-07-21 12:50:46 +03:00
parent 3b71caf7b3
commit 0d7f7a819f
105 changed files with 7185 additions and 38 deletions
-1
View File
@@ -68,7 +68,6 @@ KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
# (openssl rand -hex 32)
KEYCLOAK_OTP_HMAC_KEY=change-me
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
KEYCLOAK_ADMIN=bootstrap-admin
@@ -0,0 +1,40 @@
"""Store raw device identifiers and add OTP verification limit.
Revision ID: 0004_device_otp
Revises: 0003_consent_audit
Create Date: 2026-07-21
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0004_device_otp"
down_revision: str | None = "0003_consent_audit"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE han_app.ux_sessions
ADD COLUMN IF NOT EXISTS device_id varchar(255)
"""
)
op.execute(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, description,
record_status, updated_at)
VALUES
('otp.phone.max_verify_attempts', '5', 'integer', false,
'Maximum failed verification attempts for one OTP challenge',
'A', now())
ON CONFLICT (setting_key) DO NOTHING
"""
)
def downgrade() -> None:
raise RuntimeError("Device and OTP limits migration is forward-only")
+1
View File
@@ -103,6 +103,7 @@ class UxSession(Common, Base):
platform: Mapped[str] = mapped_column(String(32))
app_version: Mapped[str] = mapped_column(String(64))
device_id_hash: Mapped[str | None] = mapped_column(String(64))
device_id: Mapped[str | None] = mapped_column(String(255))
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+2 -1
View File
@@ -415,7 +415,7 @@ async def ready(request: Request, db: Session):
try:
await db.execute(text("SELECT 1"))
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
if revision != "0003_consent_audit":
if revision != "0004_device_otp":
raise RuntimeError("unexpected database revision")
await load_settings(db)
components["postgres"] = "ok"
@@ -979,6 +979,7 @@ async def otp_settings(
return JSONResponse({
"max_send_attempts_per_24h": settings.integer("otp.phone.max_send_attempts_per_24h"),
"min_seconds_between_attempts": settings.integer("otp.phone.min_seconds_between_attempts"),
"max_verify_attempts": settings.integer("otp.phone.max_verify_attempts"),
"version": settings.version,
"cache_ttl_seconds": 60,
}, headers=headers)
+5 -6
View File
@@ -63,6 +63,7 @@ REQUIRED_SETTINGS = {
"auth.password.enabled",
"otp.phone.max_send_attempts_per_24h",
"otp.phone.min_seconds_between_attempts",
"otp.phone.max_verify_attempts",
"operator.call.phone",
"consent.personal_data.required",
"consent.personal_data.document_url",
@@ -182,9 +183,7 @@ def device_snapshot(device: Any) -> dict[str, str | None]:
return {
"platform": device.platform,
"app_version": device.app_version,
"device_id_hash": (
hashlib.sha256(device.device_id.encode()).hexdigest() if device.device_id else None
),
"device_id": device.device_id,
}
@@ -235,7 +234,7 @@ async def bootstrap(
user_id = (await session.execute(statement)).scalar_one()
await session.execute(
insert(ClientProfile)
.values(id=uuid.uuid4(), user_id=user_id)
.values(id=uuid.uuid4(), user_id=user_id, russian_phone=principal.phone_number)
.on_conflict_do_nothing(index_elements=[ClientProfile.user_id])
)
for consent_type in ("personal_data", "user_agreement", "marketing"):
@@ -300,7 +299,7 @@ async def record_consents(
device = {
"platform": ux_session.platform,
"app_version": ux_session.app_version,
"device_id_hash": ux_session.device_id_hash,
"device_id": ux_session.device_id,
}
now = datetime.now(UTC)
versions: dict[str, str] = {}
@@ -366,7 +365,7 @@ async def start_session(
start_reason=body.start_reason,
platform=body.device.platform,
app_version=body.device.app_version,
device_id_hash=device["device_id_hash"],
device_id=device["device_id"],
started_at=now,
)
)
@@ -68,7 +68,7 @@ def test_trace_id_uses_valid_w3c_header_and_generates_fallback() -> None:
int(fallback, 16)
def test_user_agent_and_device_are_hashed_without_raw_identifiers() -> None:
def test_user_agent_is_hashed_and_device_parameters_are_preserved() -> None:
agent = "Example Browser/1.0"
assert user_agent_hash(request(user_agent=agent)) == hashlib.sha256(agent.encode()).hexdigest()
snapshot = device_snapshot(
@@ -77,9 +77,8 @@ def test_user_agent_and_device_are_hashed_without_raw_identifiers() -> None:
assert snapshot == {
"platform": "web",
"app_version": "1.2.3",
"device_id_hash": hashlib.sha256(b"raw-device-id").hexdigest(),
"device_id": "raw-device-id",
}
assert "raw-device-id" not in str(snapshot)
def test_audit_copies_request_context_and_bounded_metadata() -> None:
@@ -41,6 +41,11 @@ from app.postgres import create_postgres_engine
logger = logging.getLogger("bitrix-local-app")
BITRIX_SENDER_PREFIX = re.compile(
r"^\[b\][^\r\n\[]+:\[/b\]\s*(?:\[br\]\s*)?",
re.IGNORECASE,
)
CONNECTOR_ICON_DATA_URI = "data:image/svg+xml," + quote(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
'<path fill="#fff" d="M20 20h15v25h30V20h15v60H65V60H35v20H20z"/>'
@@ -222,6 +227,10 @@ def first(value: Any, *paths: tuple[str, ...]) -> Any:
return None
def strip_bitrix_sender_prefix(text: str) -> str:
return BITRIX_SENDER_PREFIX.sub("", text, count=1)
def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None:
event = str(payload.get("event", "")).upper()
if event not in {
@@ -257,6 +266,7 @@ def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None:
text_value = str(
first(data, ("MESSAGES", "0", "message", "text"), ("MESSAGE", "TEXT")) or ""
)
text_value = strip_bitrix_sender_prefix(text_value)
files_raw = first(data, ("MESSAGES", "0", "message", "files"), ("MESSAGE", "FILES")) or []
if isinstance(files_raw, dict):
files_raw = list(files_raw.values())
@@ -58,12 +58,36 @@ def test_normalize_message_and_finish():
assert message["event_type"] == "message.new"
assert message["external_chat_id"] == external
assert message["bitrix_message_id"] == "86497"
assert message["message"]["text"] == "Ответ"
closed = normalize_event(
{"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}}
)
assert closed["event_type"] == "dialog.closed"
def test_normalize_message_removes_bitrix_sender_prefix():
external = str(uuid.uuid4())
message = normalize_event(
{
"event": "ONIMCONNECTORMESSAGEADD",
"data": {
"MESSAGES": [
{
"im": {"message_id": 86498},
"chat": {"id": external},
"message": {
"text": "[b]Антон Пичугин:[/b] [br]опять ты?",
"files": [],
},
}
]
},
}
)
assert message["message"]["text"] == "опять ты?"
def test_normalize_bitrix_file_uses_download_url_and_infers_mime_type():
external = str(uuid.uuid4())
message = normalize_event(
@@ -4,6 +4,7 @@ settings:
auth.password.enabled: {type: boolean, value: false, public: true}
otp.phone.max_send_attempts_per_24h: {type: integer, value: 3, public: false}
otp.phone.min_seconds_between_attempts: {type: integer, value: 30, public: false}
otp.phone.max_verify_attempts: {type: integer, value: 5, public: false}
operator.call.phone: {type: string, value: "+74999591007", public: true}
consent.personal_data.required: {type: boolean, value: true, public: true}
consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true}
@@ -1,5 +1,5 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useLocalSearchParams, useRouter } from "expo-router";
import React, { useEffect, useMemo, useState } from "react";
import { Platform, ScrollView, Text, View } from "react-native";
import { useApp } from "../../src/app-context";
@@ -23,9 +23,11 @@ export default function ChatScreen() {
const { dialogId } = useLocalSearchParams<{ dialogId: string }>();
const app = useApp();
const client = useQueryClient();
const router = useRouter();
const [text, setText] = useState("");
const [error, setError] = useState<unknown>();
const [sending, setSending] = useState(false);
const [creating, setCreating] = useState(false);
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
@@ -104,6 +106,15 @@ export default function ChatScreen() {
} catch (reason) { setError(reason); }
};
const createDialog = async () => {
setCreating(true); setError(undefined);
try {
const created = await dialogApi.create(crypto.randomUUID());
router.replace(`/dialogs/${created.dialog_id}`);
} catch (reason) { setError(reason); }
finally { setCreating(false); }
};
const closed = dialog.data?.status === "closed";
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
@@ -122,7 +133,11 @@ export default function ChatScreen() {
</View>)}
{!messages.isLoading && !messages.data?.items.length && <Text style={styles.muted}>Сообщений пока нет.</Text>}
</View>
{closed ? <Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text> : <View style={styles.card}>
{closed ? <View style={styles.card}>
<Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text>
<Button title={creating ? "Создание…" : "Начать новый диалог"} disabled={creating} onPress={() => void createDialog()} />
{error && <ErrorNotice error={error} retry={() => void createDialog()} />}
</View> : <View style={styles.card}>
<Field label="Новое сообщение" multiline value={text} onChangeText={setText} />
<View style={styles.row}>
<Button title={sending ? "Отправка…" : "Отправить"} disabled={sending || !text.trim()} onPress={() => void sendText()} />
@@ -1,6 +1,6 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link } from "expo-router";
import React from "react";
import { Link, useRouter } from "expo-router";
import React, { useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import { dialogApi } from "../../src/services";
@@ -15,6 +15,9 @@ const statusLabels = {
export default function DialogsScreen() {
const app = useApp();
const router = useRouter();
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<unknown>();
const dialogs = useInfiniteQuery({
queryKey: ["dialogs"],
queryFn: ({ pageParam }) => dialogApi.list(pageParam),
@@ -22,6 +25,20 @@ export default function DialogsScreen() {
getNextPageParam: (page) => page.next_cursor ?? undefined,
enabled: app.authStatus === "authenticated",
});
const createDialog = async () => {
setCreating(true);
setCreateError(undefined);
try {
const dialog = await dialogApi.create(crypto.randomUUID());
router.push(`/dialogs/${dialog.dialog_id}`);
} catch (error) {
setCreateError(error);
} finally {
setCreating(false);
}
};
if (app.authStatus !== "authenticated") return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} />
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
@@ -33,6 +50,8 @@ export default function DialogsScreen() {
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
<Button title={creating ? "Создание…" : "Новый диалог"} disabled={creating} onPress={() => void createDialog()} />
{createError && <ErrorNotice error={createError} retry={() => void createDialog()} />}
{dialogs.isLoading && <Loading />}
{dialogs.error && <ErrorNotice error={dialogs.error} retry={() => void dialogs.refetch()} />}
{!dialogs.isLoading && !items.length && <Text style={styles.muted}>Диалогов пока нет.</Text>}
@@ -62,7 +62,6 @@ services:
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?KEYCLOAK_OTP_MOCK_CODE is required}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?KEYCLOAK_OTP_HMAC_KEY is required}
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?KEYCLOAK_SETTINGS_BRIDGE_TOKEN is required}
-1
View File
@@ -8,7 +8,6 @@ KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
KEYCLOAK_OTP_TTL_SEC=300
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=replace-with-service-token
@@ -24,7 +24,6 @@ services:
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?mock code is required}
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required}
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?settings bridge token is required}
@@ -8,7 +8,6 @@ final class Config {
static final String MOCK_CODE = required("KEYCLOAK_OTP_MOCK_CODE");
static final byte[] HMAC_KEY = required("KEYCLOAK_OTP_HMAC_KEY").getBytes(java.nio.charset.StandardCharsets.UTF_8);
static final Duration OTP_TTL = Duration.ofSeconds(integer("KEYCLOAK_OTP_TTL_SEC", 300, 30, 900));
static final int MAX_VERIFY_ATTEMPTS = integer("KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS", 5, 1, 10);
static final Duration SETTINGS_MAX_STALE = Duration.ofSeconds(
integer("KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", 300, 30, 3600));
static final URI SETTINGS_URL = URI.create(env("KEYCLOAK_SETTINGS_BRIDGE_URL",
@@ -58,7 +58,7 @@ final class OtpStore {
challenge.createdAt = now;
challenge.expiresAt = now.plus(Config.OTP_TTL);
challenge.verifyAttempts = 0;
challenge.maxVerifyAttempts = Config.MAX_VERIFY_ATTEMPTS;
challenge.maxVerifyAttempts = limits.maxVerifyAttempts();
challenge.settingsVersion = limits.version();
challenge.providerId = "mock-" + Crypto.randomId();
challenge.providerStatus = "accepted";
@@ -17,7 +17,7 @@ final class SettingsBridge {
.connectTimeout(Duration.ofSeconds(2)).build();
private static volatile Cached cached;
record Limits(int maxSendsPer24h, int minSecondsBetween, String version) {}
record Limits(int maxSendsPer24h, int minSecondsBetween, int maxVerifyAttempts, String version) {}
private record Cached(Limits limits, Instant fetchedAt, Instant refreshAfter, String etag) {}
private SettingsBridge() {}
@@ -44,12 +44,14 @@ final class SettingsBridge {
if (response.statusCode() != 200) throw new IllegalStateException("settings_http_" + response.statusCode());
int max = integer(response.body(), "max_send_attempts_per_24h");
int minimum = integer(response.body(), "min_seconds_between_attempts");
int maxVerify = integer(response.body(), "max_verify_attempts");
int ttl = integer(response.body(), "cache_ttl_seconds");
String version = string(response.body(), "version");
if (max < 1 || max > 100 || minimum < 0 || minimum > 86400 || ttl < 1 || ttl > 3600) {
if (max < 1 || max > 100 || minimum < 0 || minimum > 86400
|| maxVerify < 1 || maxVerify > 10 || ttl < 1 || ttl > 3600) {
throw new IllegalStateException("settings_invalid_range");
}
Limits limits = new Limits(max, minimum, version);
Limits limits = new Limits(max, minimum, maxVerify, version);
cached = new Cached(limits, now, now.plusSeconds(ttl),
response.headers().firstValue("ETag").orElse(null));
return limits;
-1
View File
@@ -213,7 +213,6 @@ class InfrastructureConfigTests(unittest.TestCase):
"KEYCLOAK_OTP_MOCK_CODE",
"KEYCLOAK_OTP_HMAC_KEY",
"KEYCLOAK_OTP_TTL_SEC",
"KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS",
"KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC",
"KEYCLOAK_SETTINGS_BRIDGE_URL",
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN",