Реализована интеграция с СМС провайдером

This commit is contained in:
mi
2026-07-23 11:49:15 +03:00
parent cc0163eb94
commit b1ed714d5b
89 changed files with 5934 additions and 202 deletions
@@ -0,0 +1,37 @@
"""Seed runtime OTP settings.
Revision ID: 0005_otp_settings
Revises: 0004_device_otp
Create Date: 2026-07-22
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0005_otp_settings"
down_revision: str | None = "0004_device_otp"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, description,
record_status, updated_at)
VALUES
('otp.phone.code_length', '6', 'integer', false,
'Length of the numeric phone OTP', 'A', now()),
('otp.phone.ttl_seconds', '60', 'integer', false,
'Phone OTP lifetime from durable order time', 'A', now()),
('otp.phone.sms_order_timeout_ms', '3000', 'integer', false,
'Keycloak timeout for durable SMS order creation', 'A', now())
ON CONFLICT (setting_key) DO NOTHING
"""
)
def downgrade() -> None:
raise RuntimeError("OTP runtime settings migration is forward-only")
@@ -10,6 +10,7 @@ from sqlalchemy import func, or_
from sqlalchemy.dialects.postgresql import insert
from app.db import AppSetting, Database
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.settings import get_settings
VALUE_TYPES = {"boolean", "integer", "string", "string_list"}
@@ -32,8 +33,12 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
value_type = raw.get("type")
if value_type not in VALUE_TYPES:
raise ValueError(f"{key}: unsupported type {value_type!r}")
if key in OTP_SETTING_KEYS and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if not isinstance(raw.get("public"), bool):
raise ValueError(f"{key}: public must be a boolean")
if key in OTP_SETTING_KEYS and raw["public"]:
raise ValueError(f"{key}: OTP setting must not be public")
description = raw.get("description")
if description is not None and not isinstance(description, str):
raise ValueError(f"{key}: description must be a string")
@@ -47,6 +52,7 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
"record_status": "A",
}
)
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
return rows
+11 -2
View File
@@ -58,6 +58,7 @@ from app.schemas import (
ConsentsRequest,
MessageRequest,
OpenLinesInbox,
OtpSettingsResponse,
SessionStartRequest,
decode_cursor,
encode_cursor,
@@ -415,7 +416,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 != "0004_device_otp":
if revision != "0005_otp_settings":
raise RuntimeError("unexpected database revision")
await load_settings(db)
components["postgres"] = "ok"
@@ -963,7 +964,12 @@ async def inbox(event: OpenLinesInbox, request: Request, db: Session, settings:
return JSONResponse(body, status_code=status)
@app.get("/internal/settings/v1/otp", tags=["internal"])
@app.get(
"/internal/settings/v1/otp",
tags=["internal"],
response_model=OtpSettingsResponse,
responses={304: {"description": "Cached settings are still current"}},
)
async def otp_settings(
request: Request,
settings: SnapshotDep,
@@ -980,6 +986,9 @@ async def otp_settings(
"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"),
"code_length": settings.integer("otp.phone.code_length"),
"ttl_seconds": settings.integer("otp.phone.ttl_seconds"),
"sms_order_timeout_ms": settings.integer("otp.phone.sms_order_timeout_ms"),
"version": settings.version,
"cache_ttl_seconds": 60,
}, headers=headers)
@@ -0,0 +1,44 @@
from collections.abc import Mapping
OTP_SETTING_KEYS = {
"otp.phone.max_send_attempts_per_24h",
"otp.phone.min_seconds_between_attempts",
"otp.phone.max_verify_attempts",
"otp.phone.code_length",
"otp.phone.ttl_seconds",
"otp.phone.sms_order_timeout_ms",
}
def validate_otp_settings(values: Mapping[str, str]) -> None:
parsed: dict[str, int] = {}
for key in OTP_SETTING_KEYS:
raw = values.get(key)
if raw is None:
continue
try:
value = int(raw)
except (TypeError, ValueError) as error:
raise ValueError(f"{key}: integer value expected") from error
if str(value) != raw:
raise ValueError(f"{key}: canonical integer value expected")
parsed[key] = value
positive = OTP_SETTING_KEYS - {"otp.phone.min_seconds_between_attempts"}
for key in positive:
if key in parsed and parsed[key] <= 0:
raise ValueError(f"{key}: value must be positive")
if parsed.get("otp.phone.min_seconds_between_attempts", 0) < 0:
raise ValueError("otp.phone.min_seconds_between_attempts: value must be non-negative")
code_length = parsed.get("otp.phone.code_length")
if code_length is not None and not 4 <= code_length <= 10:
raise ValueError("otp.phone.code_length: value must be between 4 and 10")
ttl_seconds = parsed.get("otp.phone.ttl_seconds")
if ttl_seconds is not None and (
not 60 <= ttl_seconds <= 900 or ttl_seconds % 60 != 0
):
raise ValueError(
"otp.phone.ttl_seconds: value must be between 60 and 900 and divisible by 60"
)
@@ -14,6 +14,17 @@ class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class OtpSettingsResponse(StrictModel):
max_send_attempts_per_24h: int = Field(strict=True, gt=0)
min_seconds_between_attempts: int = Field(strict=True, ge=0)
max_verify_attempts: int = Field(strict=True, gt=0)
code_length: int = Field(strict=True, ge=4, le=10)
ttl_seconds: int = Field(strict=True, ge=60, le=900, multiple_of=60)
sms_order_timeout_ms: int = Field(strict=True, gt=0)
version: str = Field(min_length=1, max_length=64)
cache_ttl_seconds: int = Field(strict=True, gt=0)
class Device(StrictModel):
platform: Literal["ios", "android", "web"]
app_version: str = Field(min_length=1, max_length=64)
+26 -4
View File
@@ -37,6 +37,7 @@ from app.integrations import (
SafetyClient,
fresh_openlines_payload,
)
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.realtime import RealtimeFanout
from app.schemas import (
AttachmentCompleteRequest,
@@ -84,7 +85,7 @@ REQUIRED_SETTINGS = {
"ux.session.idle_timeout_minutes",
"security.cors.allowed_origins",
"security.public_cache.max_age_seconds",
}
} | OTP_SETTING_KEYS
@dataclass(frozen=True, slots=True)
@@ -126,9 +127,11 @@ class AuditContext:
async def load_settings(session: AsyncSession) -> SettingsSnapshot:
rows = (
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
).scalars()
rows = list(
(
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
).scalars()
)
values = {row.setting_key: row.setting_value for row in rows}
missing = REQUIRED_SETTINGS - values.keys()
if missing:
@@ -138,6 +141,25 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
"Required settings are unavailable",
{"missing": sorted(missing)},
)
try:
invalid_metadata = sorted(
row.setting_key
for row in rows
if row.setting_key in OTP_SETTING_KEYS
and (row.value_type != "integer" or row.is_public)
)
if invalid_metadata:
raise ValueError(
f"OTP settings must have integer type and be private: {invalid_metadata}"
)
validate_otp_settings(values)
except ValueError as error:
raise DomainError(
"dependency_unavailable",
503,
"OTP settings are invalid",
{"reason": str(error)},
) from error
version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24]
return SettingsSnapshot(values, version)
+27 -1
View File
@@ -196,7 +196,12 @@ paths:
operationId: getOtpSettings
security: [{serviceBearer: []}]
responses:
"200": {description: Product OTP limits and cache metadata}
"200":
description: Product OTP limits and cache metadata
content:
application/json:
schema: {$ref: "#/components/schemas/OtpSettingsResponse"}
"304": {description: Cached settings are still current}
"503": {$ref: "#/components/responses/DependencyUnavailable"}
components:
securitySchemes:
@@ -218,6 +223,27 @@ components:
description: Required dependency is unavailable
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
schemas:
OtpSettingsResponse:
type: object
additionalProperties: false
required:
- max_send_attempts_per_24h
- min_seconds_between_attempts
- max_verify_attempts
- code_length
- ttl_seconds
- sms_order_timeout_ms
- version
- cache_ttl_seconds
properties:
max_send_attempts_per_24h: {type: integer, minimum: 1}
min_seconds_between_attempts: {type: integer, minimum: 0}
max_verify_attempts: {type: integer, minimum: 1}
code_length: {type: integer, minimum: 4, maximum: 10}
ttl_seconds: {type: integer, minimum: 60, maximum: 900, multipleOf: 60}
sms_order_timeout_ms: {type: integer, minimum: 1}
version: {type: string, minLength: 1, maxLength: 64}
cache_ttl_seconds: {type: integer, minimum: 1}
ErrorEnvelope:
type: object
required: [error]
@@ -1,10 +1,13 @@
import base64
import json
from pathlib import Path
from types import SimpleNamespace
import yaml
from pydantic import SecretStr
from app.main import app, websocket_token
from app.main import app, otp_settings, websocket_token
from app.services import SettingsSnapshot
EXPECTED_PATHS = {
"/health/live",
@@ -56,3 +59,68 @@ def test_websocket_accepts_canonical_base64url_jwt_protocol() -> None:
def test_committed_openapi_server_does_not_double_api_prefix() -> None:
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
assert committed["servers"] == [{"url": "/"}]
def test_otp_settings_contract_is_strict_and_complete() -> None:
generated = app.openapi()
response = generated["paths"]["/internal/settings/v1/otp"]["get"]["responses"]["200"]
schema_ref = response["content"]["application/json"]["schema"]["$ref"]
schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]]
assert set(schema["required"]) == {
"max_send_attempts_per_24h",
"min_seconds_between_attempts",
"max_verify_attempts",
"code_length",
"ttl_seconds",
"sms_order_timeout_ms",
"version",
"cache_ttl_seconds",
}
assert schema["additionalProperties"] is False
assert schema["properties"]["code_length"] == {
"type": "integer",
"maximum": 10.0,
"minimum": 4.0,
"title": "Code Length",
}
assert schema["properties"]["ttl_seconds"]["multipleOf"] == 60
async def test_otp_settings_returns_runtime_values_and_supports_etag() -> None:
request = SimpleNamespace(
headers={"Authorization": "Bearer bridge-token"},
app=SimpleNamespace(
state=SimpleNamespace(
settings=SimpleNamespace(
keycloak_settings_bridge_token=SecretStr("bridge-token")
)
)
),
)
settings = SettingsSnapshot(
{
"otp.phone.max_send_attempts_per_24h": "3",
"otp.phone.min_seconds_between_attempts": "30",
"otp.phone.max_verify_attempts": "5",
"otp.phone.code_length": "6",
"otp.phone.ttl_seconds": "60",
"otp.phone.sms_order_timeout_ms": "3000",
},
"settings-version",
)
response = await otp_settings(request, settings)
assert json.loads(response.body) == {
"max_send_attempts_per_24h": 3,
"min_seconds_between_attempts": 30,
"max_verify_attempts": 5,
"code_length": 6,
"ttl_seconds": 60,
"sms_order_timeout_ms": 3000,
"version": "settings-version",
"cache_ttl_seconds": 60,
}
cached = await otp_settings(request, settings, response.headers["etag"])
assert cached.status_code == 304
@@ -12,6 +12,10 @@ def test_production_like_seed_contains_all_mandatory_settings() -> None:
assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows}
assert all(row["record_status"] == "A" for row in rows)
values = {row["setting_key"]: row["setting_value"] for row in rows}
assert values["otp.phone.code_length"] == "6"
assert values["otp.phone.ttl_seconds"] == "60"
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
@@ -24,3 +28,37 @@ def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="integer value expected"):
load_seed(path)
@pytest.mark.parametrize(
("key", "value", "message"),
[
("otp.phone.code_length", 3, "between 4 and 10"),
("otp.phone.ttl_seconds", 61, "divisible by 60"),
("otp.phone.sms_order_timeout_ms", 0, "must be positive"),
],
)
def test_seed_rejects_invalid_otp_settings(
tmp_path: Path, key: str, value: int, message: str
) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
f" {key}: {{type: integer, value: {value}, public: false}}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match=message):
load_seed(path)
def test_seed_rejects_public_otp_setting(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
" otp.phone.code_length: {type: integer, value: 6, public: true}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="must not be public"):
load_seed(path)