Реализованы задачи бэклога 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
@@ -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: