Реализована проверка версионности и требование soft\force обновлений приложения
This commit is contained in:
@@ -84,3 +84,18 @@ pytest
|
||||
зависимости read API. Remote Message Safety не выключает чтение/общую readiness:
|
||||
send endpoint отдельно проверяет требуемую capability и fail-closed возвращает
|
||||
`503`, если ВМ2 недоступна.
|
||||
|
||||
## Публичная конфигурация мобильных обновлений
|
||||
|
||||
`GET /api/v1/public/app-config` возвращает строгий объект `mobile_update` с
|
||||
политиками `google_play`, `rustore` и `app_store`. Для включённого магазина
|
||||
обязательны `latest_build`, `minimum_build`, `latest_version` и HTTPS `store_url`;
|
||||
`minimum_build` не может превышать `latest_build`. Для отключённого магазина
|
||||
эти поля возвращаются как `null`. Опциональное `release_notes` также возвращается
|
||||
как `null`, если в settings задана пустая строка. Канонический URL RuStore:
|
||||
`https://www.rustore.ru/catalog/app/ru.han.chat`.
|
||||
|
||||
Ответ содержит `ETag`, поддерживает `If-None-Match` с ответом `304` и в
|
||||
production-like конфигурации кэшируется клиентом и nginx 60 секунд. Изменения
|
||||
политики применяются через штатный идемпотентный `seed-settings`; некорректные
|
||||
пороги, типы и URL блокируют загрузку settings.
|
||||
|
||||
@@ -11,6 +11,12 @@ from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings
|
||||
from app.db import AppSetting, Database
|
||||
from app.mobile_update_settings import (
|
||||
MOBILE_UPDATE_BOOLEAN_KEYS,
|
||||
MOBILE_UPDATE_INTEGER_KEYS,
|
||||
MOBILE_UPDATE_SETTING_KEYS,
|
||||
validate_mobile_update_settings,
|
||||
)
|
||||
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
||||
from app.settings import get_settings
|
||||
|
||||
@@ -38,12 +44,25 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
|
||||
raise ValueError(f"{key}: type must be integer")
|
||||
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer":
|
||||
raise ValueError(f"{key}: type must be integer")
|
||||
if key in MOBILE_UPDATE_BOOLEAN_KEYS and value_type != "boolean":
|
||||
raise ValueError(f"{key}: type must be boolean")
|
||||
if key in MOBILE_UPDATE_INTEGER_KEYS and value_type != "integer":
|
||||
raise ValueError(f"{key}: type must be integer")
|
||||
if (
|
||||
key in MOBILE_UPDATE_SETTING_KEYS
|
||||
- MOBILE_UPDATE_BOOLEAN_KEYS
|
||||
- MOBILE_UPDATE_INTEGER_KEYS
|
||||
and value_type != "string"
|
||||
):
|
||||
raise ValueError(f"{key}: type must be string")
|
||||
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")
|
||||
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]:
|
||||
raise ValueError(f"{key}: setting must be public")
|
||||
if key in MOBILE_UPDATE_SETTING_KEYS and not raw["public"]:
|
||||
raise ValueError(f"{key}: mobile update setting must 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")
|
||||
@@ -59,6 +78,9 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
|
||||
)
|
||||
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
|
||||
validate_chat_settings({row["setting_key"]: row["setting_value"] for row in rows})
|
||||
validate_mobile_update_settings(
|
||||
{row["setting_key"]: row["setting_value"] for row in rows}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -68,6 +90,8 @@ def serialize_value(key: str, value_type: str, value: Any) -> str:
|
||||
raise ValueError(f"{key}: boolean value expected")
|
||||
return str(value).lower()
|
||||
if value_type == "integer":
|
||||
if key in MOBILE_UPDATE_INTEGER_KEYS and value is None:
|
||||
return ""
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"{key}: integer value expected")
|
||||
return str(value)
|
||||
|
||||
@@ -63,6 +63,7 @@ from app.schemas import (
|
||||
MessageRequest,
|
||||
OpenLinesInbox,
|
||||
OtpSettingsResponse,
|
||||
PublicAppConfigResponse,
|
||||
SessionStartRequest,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
@@ -192,6 +193,15 @@ app.include_router(notification_router)
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
def etag_matches(if_none_match: str | None, etag: str) -> bool:
|
||||
if not if_none_match:
|
||||
return False
|
||||
return any(
|
||||
candidate.strip().removeprefix("W/") in {"*", etag}
|
||||
for candidate in if_none_match.split(",")
|
||||
)
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str | None:
|
||||
"""Trust forwarded client addresses only from configured reverse proxies."""
|
||||
peer = request.client.host if request.client else ""
|
||||
@@ -501,8 +511,17 @@ async def ready(request: Request, db: Session):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/public/app-config", tags=["public"])
|
||||
async def app_config(request: Request, response: Response, settings: SnapshotDep):
|
||||
@app.get(
|
||||
"/api/v1/public/app-config",
|
||||
tags=["public"],
|
||||
response_model=PublicAppConfigResponse,
|
||||
responses={304: {"description": "Cached configuration is still current"}},
|
||||
)
|
||||
async def app_config(
|
||||
request: Request,
|
||||
settings: SnapshotDep,
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
):
|
||||
await enforce_limit(
|
||||
request,
|
||||
"ip",
|
||||
@@ -511,12 +530,15 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
|
||||
settings.limit("rate_limit.public_endpoints.per_ip"),
|
||||
fail_closed=False,
|
||||
)
|
||||
response.headers["Cache-Control"] = (
|
||||
headers = {"Cache-Control": (
|
||||
f"public, max-age={settings.integer('security.public_cache.max_age_seconds')}"
|
||||
)
|
||||
response.headers["ETag"] = f'"{settings.version}"'
|
||||
)}
|
||||
etag = f'"{settings.version}"'
|
||||
headers["ETag"] = etag
|
||||
if etag_matches(if_none_match, etag):
|
||||
return Response(status_code=304, headers=headers)
|
||||
values = settings.values
|
||||
return {
|
||||
body = {
|
||||
"auth": {
|
||||
"phone_enabled": settings.boolean("auth.phone.enabled"),
|
||||
"password_enabled": settings.boolean("auth.password.enabled"),
|
||||
@@ -557,7 +579,28 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
|
||||
),
|
||||
},
|
||||
"ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")},
|
||||
"mobile_update": {
|
||||
store: {
|
||||
"enabled": settings.boolean(f"mobile_update.{store}.enabled"),
|
||||
"latest_build": (
|
||||
settings.integer(f"mobile_update.{store}.latest_build")
|
||||
if values[f"mobile_update.{store}.latest_build"]
|
||||
else None
|
||||
),
|
||||
"minimum_build": (
|
||||
settings.integer(f"mobile_update.{store}.minimum_build")
|
||||
if values[f"mobile_update.{store}.minimum_build"]
|
||||
else None
|
||||
),
|
||||
"latest_version": values[f"mobile_update.{store}.latest_version"] or None,
|
||||
"store_url": values[f"mobile_update.{store}.store_url"] or None,
|
||||
"release_notes": values[f"mobile_update.{store}.release_notes"] or None,
|
||||
}
|
||||
for store in ("google_play", "rustore", "app_store")
|
||||
},
|
||||
}
|
||||
validated = PublicAppConfigResponse.model_validate(body)
|
||||
return JSONResponse(validated.model_dump(mode="json"), headers=headers)
|
||||
|
||||
|
||||
@app.get("/api/v1/public/content", tags=["public"])
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from collections.abc import Mapping
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
MOBILE_UPDATE_STORES = ("google_play", "rustore", "app_store")
|
||||
MOBILE_UPDATE_FIELDS = (
|
||||
"enabled",
|
||||
"latest_build",
|
||||
"minimum_build",
|
||||
"latest_version",
|
||||
"store_url",
|
||||
"release_notes",
|
||||
)
|
||||
MOBILE_UPDATE_SETTING_KEYS = {
|
||||
f"mobile_update.{store}.{field}"
|
||||
for store in MOBILE_UPDATE_STORES
|
||||
for field in MOBILE_UPDATE_FIELDS
|
||||
}
|
||||
MOBILE_UPDATE_BOOLEAN_KEYS = {
|
||||
f"mobile_update.{store}.enabled" for store in MOBILE_UPDATE_STORES
|
||||
}
|
||||
MOBILE_UPDATE_INTEGER_KEYS = {
|
||||
f"mobile_update.{store}.{field}"
|
||||
for store in MOBILE_UPDATE_STORES
|
||||
for field in ("latest_build", "minimum_build")
|
||||
}
|
||||
|
||||
ANDROID_PACKAGE = "ru.han.chat"
|
||||
|
||||
|
||||
def validate_mobile_update_settings(values: Mapping[str, str]) -> None:
|
||||
for store in MOBILE_UPDATE_STORES:
|
||||
prefix = f"mobile_update.{store}"
|
||||
required = [f"{prefix}.{field}" for field in MOBILE_UPDATE_FIELDS]
|
||||
present = [key for key in required if key in values]
|
||||
if not present:
|
||||
continue
|
||||
if len(present) != len(required):
|
||||
missing = sorted(set(required) - values.keys())
|
||||
raise ValueError(f"{prefix}: incomplete policy; missing {missing}")
|
||||
|
||||
enabled = _boolean(values[f"{prefix}.enabled"], f"{prefix}.enabled")
|
||||
latest_build = _optional_build(values[f"{prefix}.latest_build"], f"{prefix}.latest_build")
|
||||
minimum_build = _optional_build(
|
||||
values[f"{prefix}.minimum_build"], f"{prefix}.minimum_build"
|
||||
)
|
||||
latest_version = values[f"{prefix}.latest_version"].strip()
|
||||
store_url = values[f"{prefix}.store_url"].strip()
|
||||
release_notes = values[f"{prefix}.release_notes"].strip()
|
||||
if len(release_notes) > 4000:
|
||||
raise ValueError(f"{prefix}.release_notes: value must not exceed 4000 characters")
|
||||
|
||||
if not enabled:
|
||||
if any(
|
||||
value not in (None, "")
|
||||
for value in (
|
||||
latest_build,
|
||||
minimum_build,
|
||||
latest_version,
|
||||
store_url,
|
||||
)
|
||||
):
|
||||
raise ValueError(f"{prefix}: disabled policy fields must be empty")
|
||||
continue
|
||||
|
||||
if latest_build is None or minimum_build is None:
|
||||
raise ValueError(f"{prefix}: enabled policy requires build thresholds")
|
||||
if minimum_build > latest_build:
|
||||
raise ValueError(f"{prefix}: minimum_build must not exceed latest_build")
|
||||
if not latest_version:
|
||||
raise ValueError(f"{prefix}: enabled policy requires latest_version")
|
||||
_validate_store_url(store, store_url, f"{prefix}.store_url")
|
||||
|
||||
|
||||
def _boolean(raw: str, key: str) -> bool:
|
||||
if raw not in {"true", "false"}:
|
||||
raise ValueError(f"{key}: canonical boolean value expected")
|
||||
return raw == "true"
|
||||
|
||||
|
||||
def _optional_build(raw: str, key: str) -> int | None:
|
||||
if raw == "":
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{key}: integer value expected") from error
|
||||
if str(value) != raw or value < 1:
|
||||
raise ValueError(f"{key}: positive canonical integer value expected")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_store_url(store: str, raw: str, key: str) -> None:
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
|
||||
raise ValueError(f"{key}: absolute HTTPS URL expected")
|
||||
if parsed.fragment:
|
||||
raise ValueError(f"{key}: URL fragments are not allowed")
|
||||
|
||||
host = parsed.hostname.lower()
|
||||
if store == "google_play":
|
||||
package = parse_qs(parsed.query).get("id", [])
|
||||
valid = host == "play.google.com" and parsed.path == "/store/apps/details"
|
||||
valid = valid and package == [ANDROID_PACKAGE]
|
||||
elif store == "rustore":
|
||||
valid = host == "www.rustore.ru" and parsed.path == (
|
||||
f"/catalog/app/{ANDROID_PACKAGE}"
|
||||
)
|
||||
valid = valid and not parsed.query
|
||||
else:
|
||||
valid = host == "apps.apple.com" and "/id" in parsed.path
|
||||
if not valid:
|
||||
raise ValueError(f"{key}: URL does not match the {store} application")
|
||||
@@ -27,6 +27,96 @@ class OtpSettingsResponse(StrictModel):
|
||||
cache_ttl_seconds: int = Field(strict=True, gt=0)
|
||||
|
||||
|
||||
class PublicAuthConfig(StrictModel):
|
||||
phone_enabled: bool
|
||||
password_enabled: bool
|
||||
|
||||
|
||||
class PublicOperatorConfig(StrictModel):
|
||||
call_phone: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class PublicMessagesConfig(StrictModel):
|
||||
max_text_length: int = Field(strict=True, ge=1, le=CHAT_MESSAGE_TRANSPORT_MAX_LENGTH)
|
||||
|
||||
|
||||
class PublicConsentConfig(StrictModel):
|
||||
required: bool
|
||||
document_url: HttpUrl
|
||||
version: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class PublicPersonalDataConsentConfig(PublicConsentConfig):
|
||||
privacy_policy_document_url: HttpUrl
|
||||
|
||||
|
||||
class PublicConsentsConfig(StrictModel):
|
||||
personal_data: PublicPersonalDataConsentConfig
|
||||
user_agreement: PublicConsentConfig
|
||||
marketing: PublicConsentConfig
|
||||
|
||||
|
||||
class PublicAttachmentsConfig(StrictModel):
|
||||
allowed_extensions: list[str]
|
||||
allowed_mime_types: list[str]
|
||||
max_size_mb: int = Field(strict=True, gt=0)
|
||||
|
||||
|
||||
class PublicNotificationConfig(StrictModel):
|
||||
carousel_autoplay_enabled: bool
|
||||
carousel_autoplay_interval_ms: int = Field(strict=True, gt=0)
|
||||
|
||||
|
||||
class PublicUxConfig(StrictModel):
|
||||
idle_timeout_minutes: int = Field(strict=True, gt=0)
|
||||
|
||||
|
||||
class MobileStoreUpdatePolicy(StrictModel):
|
||||
enabled: bool
|
||||
latest_build: int | None = Field(strict=True, ge=1)
|
||||
minimum_build: int | None = Field(strict=True, ge=1)
|
||||
latest_version: str | None = Field(min_length=1, max_length=64)
|
||||
store_url: HttpUrl | None
|
||||
release_notes: str | None = Field(max_length=4000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_policy(self) -> "MobileStoreUpdatePolicy":
|
||||
release_fields = (
|
||||
self.latest_build,
|
||||
self.minimum_build,
|
||||
self.latest_version,
|
||||
self.store_url,
|
||||
)
|
||||
if self.enabled and any(value is None for value in release_fields):
|
||||
raise ValueError("enabled update policy requires all release fields")
|
||||
if not self.enabled and any(value is not None for value in release_fields):
|
||||
raise ValueError("disabled update policy must not expose release fields")
|
||||
if (
|
||||
self.minimum_build is not None
|
||||
and self.latest_build is not None
|
||||
and self.minimum_build > self.latest_build
|
||||
):
|
||||
raise ValueError("minimum_build must not exceed latest_build")
|
||||
return self
|
||||
|
||||
|
||||
class MobileUpdatePolicy(StrictModel):
|
||||
google_play: MobileStoreUpdatePolicy
|
||||
rustore: MobileStoreUpdatePolicy
|
||||
app_store: MobileStoreUpdatePolicy
|
||||
|
||||
|
||||
class PublicAppConfigResponse(StrictModel):
|
||||
auth: PublicAuthConfig
|
||||
operator: PublicOperatorConfig
|
||||
messages: PublicMessagesConfig
|
||||
consents: PublicConsentsConfig
|
||||
attachments: PublicAttachmentsConfig
|
||||
notification: PublicNotificationConfig
|
||||
ux: PublicUxConfig
|
||||
mobile_update: MobileUpdatePolicy
|
||||
|
||||
|
||||
class Device(StrictModel):
|
||||
platform: Literal["ios", "android", "web"]
|
||||
app_version: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@@ -38,6 +38,12 @@ from app.integrations import (
|
||||
SafetyClient,
|
||||
fresh_openlines_payload,
|
||||
)
|
||||
from app.mobile_update_settings import (
|
||||
MOBILE_UPDATE_BOOLEAN_KEYS,
|
||||
MOBILE_UPDATE_INTEGER_KEYS,
|
||||
MOBILE_UPDATE_SETTING_KEYS,
|
||||
validate_mobile_update_settings,
|
||||
)
|
||||
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
||||
from app.realtime import RealtimeFanout
|
||||
from app.schemas import (
|
||||
@@ -226,7 +232,7 @@ REQUIRED_SETTINGS = {
|
||||
"rate_limit.notification_upload.per_user",
|
||||
"rate_limit.notifications_public.per_ip",
|
||||
CHAT_MESSAGE_MAX_LENGTH_KEY,
|
||||
} | OTP_SETTING_KEYS
|
||||
} | OTP_SETTING_KEYS | MOBILE_UPDATE_SETTING_KEYS
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -286,15 +292,33 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
|
||||
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 (
|
||||
row.setting_key in OTP_SETTING_KEYS
|
||||
and (row.value_type != "integer" or row.is_public)
|
||||
)
|
||||
or (
|
||||
row.setting_key in MOBILE_UPDATE_BOOLEAN_KEYS
|
||||
and (row.value_type != "boolean" or not row.is_public)
|
||||
)
|
||||
or (
|
||||
row.setting_key in MOBILE_UPDATE_INTEGER_KEYS
|
||||
and (row.value_type != "integer" or not row.is_public)
|
||||
)
|
||||
or (
|
||||
row.setting_key
|
||||
in MOBILE_UPDATE_SETTING_KEYS
|
||||
- MOBILE_UPDATE_BOOLEAN_KEYS
|
||||
- MOBILE_UPDATE_INTEGER_KEYS
|
||||
and (row.value_type != "string" or not row.is_public)
|
||||
)
|
||||
)
|
||||
if invalid_metadata:
|
||||
raise ValueError(
|
||||
f"OTP settings must have integer type and be private: {invalid_metadata}"
|
||||
f"Settings metadata is invalid: {invalid_metadata}"
|
||||
)
|
||||
validate_otp_settings(values)
|
||||
validate_chat_settings(values)
|
||||
validate_mobile_update_settings(values)
|
||||
except ValueError as error:
|
||||
raise DomainError(
|
||||
"dependency_unavailable",
|
||||
|
||||
@@ -19,20 +19,15 @@ paths:
|
||||
/api/v1/public/app-config:
|
||||
get:
|
||||
operationId: getPublicAppConfig
|
||||
parameters:
|
||||
- {name: If-None-Match, in: header, schema: {type: string}}
|
||||
responses:
|
||||
"200":
|
||||
description: Public application configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [messages]
|
||||
properties:
|
||||
messages:
|
||||
type: object
|
||||
required: [max_text_length]
|
||||
properties:
|
||||
max_text_length: {type: integer, minimum: 1, maximum: 10000}
|
||||
schema: {$ref: "#/components/schemas/PublicAppConfigResponse"}
|
||||
"304": {description: Cached configuration is still current}
|
||||
/api/v1/public/content:
|
||||
get:
|
||||
operationId: getPublicContent
|
||||
@@ -405,6 +400,105 @@ components:
|
||||
description: Catalog action is not allowed
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
schemas:
|
||||
PublicAppConfigResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [auth, operator, messages, consents, attachments, notification, ux, mobile_update]
|
||||
properties:
|
||||
auth: {$ref: "#/components/schemas/PublicAuthConfig"}
|
||||
operator: {$ref: "#/components/schemas/PublicOperatorConfig"}
|
||||
messages: {$ref: "#/components/schemas/PublicMessagesConfig"}
|
||||
consents: {$ref: "#/components/schemas/PublicConsentsConfig"}
|
||||
attachments: {$ref: "#/components/schemas/PublicAttachmentsConfig"}
|
||||
notification: {$ref: "#/components/schemas/PublicNotificationConfig"}
|
||||
ux: {$ref: "#/components/schemas/PublicUxConfig"}
|
||||
mobile_update: {$ref: "#/components/schemas/MobileUpdatePolicy"}
|
||||
PublicAuthConfig:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [phone_enabled, password_enabled]
|
||||
properties:
|
||||
phone_enabled: {type: boolean}
|
||||
password_enabled: {type: boolean}
|
||||
PublicOperatorConfig:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [call_phone]
|
||||
properties:
|
||||
call_phone: {type: string, minLength: 1, maxLength: 32}
|
||||
PublicMessagesConfig:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [max_text_length]
|
||||
properties:
|
||||
max_text_length: {type: integer, minimum: 1, maximum: 10000}
|
||||
PublicConsentConfig:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [required, document_url, version]
|
||||
properties:
|
||||
required: {type: boolean}
|
||||
document_url: {type: string, format: uri, minLength: 1}
|
||||
version: {type: string, minLength: 1, maxLength: 64}
|
||||
PublicPersonalDataConsentConfig:
|
||||
allOf:
|
||||
- {$ref: "#/components/schemas/PublicConsentConfig"}
|
||||
- type: object
|
||||
additionalProperties: false
|
||||
required: [required, document_url, version, privacy_policy_document_url]
|
||||
properties:
|
||||
required: {type: boolean}
|
||||
document_url: {type: string, format: uri, minLength: 1}
|
||||
version: {type: string, minLength: 1, maxLength: 64}
|
||||
privacy_policy_document_url: {type: string, format: uri, minLength: 1}
|
||||
PublicConsentsConfig:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [personal_data, user_agreement, marketing]
|
||||
properties:
|
||||
personal_data: {$ref: "#/components/schemas/PublicPersonalDataConsentConfig"}
|
||||
user_agreement: {$ref: "#/components/schemas/PublicConsentConfig"}
|
||||
marketing: {$ref: "#/components/schemas/PublicConsentConfig"}
|
||||
PublicAttachmentsConfig:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [allowed_extensions, allowed_mime_types, max_size_mb]
|
||||
properties:
|
||||
allowed_extensions: {type: array, items: {type: string}}
|
||||
allowed_mime_types: {type: array, items: {type: string}}
|
||||
max_size_mb: {type: integer, minimum: 1}
|
||||
PublicNotificationConfig:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [carousel_autoplay_enabled, carousel_autoplay_interval_ms]
|
||||
properties:
|
||||
carousel_autoplay_enabled: {type: boolean}
|
||||
carousel_autoplay_interval_ms: {type: integer, minimum: 1}
|
||||
PublicUxConfig:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [idle_timeout_minutes]
|
||||
properties:
|
||||
idle_timeout_minutes: {type: integer, minimum: 1}
|
||||
MobileStoreUpdatePolicy:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [enabled, latest_build, minimum_build, latest_version, store_url, release_notes]
|
||||
properties:
|
||||
enabled: {type: boolean}
|
||||
latest_build: {type: [integer, "null"], minimum: 1}
|
||||
minimum_build: {type: [integer, "null"], minimum: 1}
|
||||
latest_version: {type: [string, "null"], minLength: 1, maxLength: 64}
|
||||
store_url: {type: [string, "null"], format: uri, minLength: 1}
|
||||
release_notes: {type: [string, "null"], maxLength: 4000}
|
||||
MobileUpdatePolicy:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [google_play, rustore, app_store]
|
||||
properties:
|
||||
google_play: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"}
|
||||
rustore: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"}
|
||||
app_store: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"}
|
||||
OtpSettingsResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -13,6 +13,7 @@ from pydantic import SecretStr
|
||||
from app.main import (
|
||||
EXPECTED_API_DB_REVISION,
|
||||
app,
|
||||
app_config,
|
||||
otp_settings,
|
||||
refresh_jwks_cache,
|
||||
websocket_token,
|
||||
@@ -152,13 +153,116 @@ def test_committed_openapi_server_does_not_double_api_prefix() -> None:
|
||||
assert committed["servers"] == [{"url": "/"}]
|
||||
|
||||
|
||||
def test_public_config_contract_exposes_message_length() -> None:
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
response = committed["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
|
||||
messages = response["content"]["application/json"]["schema"]["properties"]["messages"]
|
||||
def test_public_config_contract_is_strict_and_exposes_mobile_update() -> None:
|
||||
generated = app.openapi()
|
||||
response = generated["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
|
||||
schema_ref = response["content"]["application/json"]["schema"]["$ref"]
|
||||
schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]]
|
||||
|
||||
assert messages["required"] == ["max_text_length"]
|
||||
assert messages["properties"]["max_text_length"]["maximum"] == 10_000
|
||||
assert schema["additionalProperties"] is False
|
||||
assert set(schema["required"]) == {
|
||||
"auth",
|
||||
"operator",
|
||||
"messages",
|
||||
"consents",
|
||||
"attachments",
|
||||
"notification",
|
||||
"ux",
|
||||
"mobile_update",
|
||||
}
|
||||
mobile_ref = schema["properties"]["mobile_update"]["$ref"]
|
||||
mobile = generated["components"]["schemas"][mobile_ref.rsplit("/", 1)[-1]]
|
||||
assert mobile["additionalProperties"] is False
|
||||
assert set(mobile["required"]) == {"google_play", "rustore", "app_store"}
|
||||
store_ref = mobile["properties"]["rustore"]["$ref"]
|
||||
store = generated["components"]["schemas"][store_ref.rsplit("/", 1)[-1]]
|
||||
assert "release_notes" in store["required"]
|
||||
release_notes = store["properties"]["release_notes"]
|
||||
assert {"type": "string", "maxLength": 4000} in release_notes["anyOf"]
|
||||
assert {"type": "null"} in release_notes["anyOf"]
|
||||
assert "304" in generated["paths"]["/api/v1/public/app-config"]["get"]["responses"]
|
||||
|
||||
|
||||
async def test_public_config_returns_mobile_policy_and_supports_etag(monkeypatch) -> None:
|
||||
values = {
|
||||
"rate_limit.public_endpoints.per_ip": "60/minute",
|
||||
"security.public_cache.max_age_seconds": "60",
|
||||
"auth.phone.enabled": "true",
|
||||
"auth.password.enabled": "false",
|
||||
"operator.call.phone": "+74999591007",
|
||||
"chat.message.max_length": "4000",
|
||||
"consent.personal_data.required": "true",
|
||||
"consent.personal_data.document_url": "https://example.ru/personal",
|
||||
"consent.privacy_policy.document_url": "https://example.ru/privacy",
|
||||
"consent.personal_data.version": "2026-06-10",
|
||||
"consent.user_agreement.required": "true",
|
||||
"consent.user_agreement.document_url": "https://example.ru/agreement",
|
||||
"consent.user_agreement.version": "2026-06-10",
|
||||
"consent.marketing.required": "false",
|
||||
"consent.marketing.document_url": "https://example.ru/marketing",
|
||||
"consent.marketing.version": "2026-06-10",
|
||||
"chat.attachments.allowed_extensions": "jpg,pdf",
|
||||
"chat.attachments.allowed_mime_types": "image/jpeg,application/pdf",
|
||||
"chat.attachments.max_size_mb": "5",
|
||||
"notification.carousel.autoplay_enabled": "false",
|
||||
"notification.carousel.autoplay_interval_ms": "5000",
|
||||
"ux.session.idle_timeout_minutes": "30",
|
||||
"mobile_update.google_play.enabled": "true",
|
||||
"mobile_update.google_play.latest_build": "2",
|
||||
"mobile_update.google_play.minimum_build": "1",
|
||||
"mobile_update.google_play.latest_version": "1.0.1",
|
||||
"mobile_update.google_play.store_url": (
|
||||
"https://play.google.com/store/apps/details?id=ru.han.chat"
|
||||
),
|
||||
"mobile_update.google_play.release_notes": "",
|
||||
"mobile_update.rustore.enabled": "true",
|
||||
"mobile_update.rustore.latest_build": "2",
|
||||
"mobile_update.rustore.minimum_build": "1",
|
||||
"mobile_update.rustore.latest_version": "1.0.1",
|
||||
"mobile_update.rustore.store_url": (
|
||||
"https://www.rustore.ru/catalog/app/ru.han.chat"
|
||||
),
|
||||
"mobile_update.rustore.release_notes": "",
|
||||
"mobile_update.app_store.enabled": "false",
|
||||
"mobile_update.app_store.latest_build": "",
|
||||
"mobile_update.app_store.minimum_build": "",
|
||||
"mobile_update.app_store.latest_version": "",
|
||||
"mobile_update.app_store.store_url": "",
|
||||
"mobile_update.app_store.release_notes": "",
|
||||
}
|
||||
settings = SettingsSnapshot(values, "settings-version")
|
||||
request = SimpleNamespace(
|
||||
headers={},
|
||||
client=None,
|
||||
app=SimpleNamespace(state=SimpleNamespace()),
|
||||
)
|
||||
|
||||
async def no_limit(*args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.main.enforce_limit", no_limit)
|
||||
|
||||
response = await app_config(request, settings)
|
||||
body = json.loads(response.body)
|
||||
assert response.headers["etag"] == '"settings-version"'
|
||||
assert response.headers["cache-control"] == "public, max-age=60"
|
||||
assert body["mobile_update"]["google_play"]["latest_build"] == 2
|
||||
assert body["mobile_update"]["google_play"]["release_notes"] is None
|
||||
assert body["mobile_update"]["rustore"]["store_url"] == (
|
||||
"https://www.rustore.ru/catalog/app/ru.han.chat"
|
||||
)
|
||||
assert body["mobile_update"]["app_store"] == {
|
||||
"enabled": False,
|
||||
"latest_build": None,
|
||||
"minimum_build": None,
|
||||
"latest_version": None,
|
||||
"store_url": None,
|
||||
"release_notes": None,
|
||||
}
|
||||
|
||||
cached = await app_config(request, settings, 'W/"settings-version", "old"')
|
||||
assert cached.status_code == 304
|
||||
assert cached.headers["etag"] == '"settings-version"'
|
||||
|
||||
|
||||
def test_otp_settings_contract_is_strict_and_complete() -> None:
|
||||
|
||||
@@ -17,6 +17,17 @@ def test_production_like_seed_contains_all_mandatory_settings() -> None:
|
||||
assert values["otp.phone.ttl_seconds"] == "60"
|
||||
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
|
||||
assert values["chat.message.max_length"] == "4000"
|
||||
assert values["mobile_update.google_play.latest_build"] == "2"
|
||||
assert values["mobile_update.google_play.minimum_build"] == "1"
|
||||
assert values["mobile_update.google_play.latest_version"] == "1.0.1"
|
||||
assert values["mobile_update.rustore.latest_build"] == "2"
|
||||
assert values["mobile_update.rustore.store_url"] == (
|
||||
"https://www.rustore.ru/catalog/app/ru.han.chat"
|
||||
)
|
||||
assert values["mobile_update.rustore.release_notes"] == ""
|
||||
assert values["mobile_update.app_store.enabled"] == "false"
|
||||
assert values["mobile_update.app_store.latest_build"] == ""
|
||||
assert values["security.public_cache.max_age_seconds"] == "60"
|
||||
|
||||
|
||||
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
|
||||
@@ -76,3 +87,98 @@ def test_seed_rejects_invalid_chat_message_max_length(tmp_path: Path, value: int
|
||||
|
||||
with pytest.raises(ValueError, match="value must be between 1 and 10000"):
|
||||
load_seed(path)
|
||||
|
||||
|
||||
def _mobile_policy_yaml(
|
||||
*,
|
||||
store: str = "google_play",
|
||||
enabled: bool = True,
|
||||
latest_build: int = 2,
|
||||
minimum_build: int = 1,
|
||||
store_url: str = "https://play.google.com/store/apps/details?id=ru.han.chat",
|
||||
) -> str:
|
||||
enabled_yaml = str(enabled).lower()
|
||||
return (
|
||||
"schema_version: 1\nsettings:\n"
|
||||
f" mobile_update.{store}.enabled: "
|
||||
f"{{type: boolean, value: {enabled_yaml}, public: true}}\n"
|
||||
f" mobile_update.{store}.latest_build: "
|
||||
f"{{type: integer, value: {latest_build}, public: true}}\n"
|
||||
f" mobile_update.{store}.minimum_build: "
|
||||
f"{{type: integer, value: {minimum_build}, public: true}}\n"
|
||||
f' mobile_update.{store}.latest_version: '
|
||||
'{type: string, value: "1.0.1", public: true}\n'
|
||||
f' mobile_update.{store}.store_url: '
|
||||
f'{{type: string, value: "{store_url}", public: true}}\n'
|
||||
f' mobile_update.{store}.release_notes: '
|
||||
'{type: string, value: "", public: true}\n'
|
||||
)
|
||||
|
||||
|
||||
def test_seed_rejects_inverted_mobile_build_thresholds(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
_mobile_policy_yaml(latest_build=1, minimum_build=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="minimum_build must not exceed latest_build"):
|
||||
load_seed(path)
|
||||
|
||||
|
||||
def test_seed_rejects_wrong_mobile_store_url(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
_mobile_policy_yaml(
|
||||
store_url="https://play.google.com/store/apps/details?id=other.package"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not match the google_play application"):
|
||||
load_seed(path)
|
||||
|
||||
|
||||
def test_seed_rejects_non_https_mobile_store_url(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
_mobile_policy_yaml(
|
||||
store_url="http://play.google.com/store/apps/details?id=ru.han.chat"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="absolute HTTPS URL expected"):
|
||||
load_seed(path)
|
||||
|
||||
|
||||
def test_seed_accepts_canonical_rustore_url(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
_mobile_policy_yaml(
|
||||
store="rustore",
|
||||
store_url="https://www.rustore.ru/catalog/app/ru.han.chat",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
rows = load_seed(path)
|
||||
assert any(
|
||||
row["setting_key"] == "mobile_update.rustore.store_url"
|
||||
and row["setting_value"] == "https://www.rustore.ru/catalog/app/ru.han.chat"
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def test_seed_rejects_legacy_rustore_url(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
_mobile_policy_yaml(
|
||||
store="rustore",
|
||||
store_url="https://apps.rustore.ru/app/ru.han.chat",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not match the rustore application"):
|
||||
load_seed(path)
|
||||
|
||||
Reference in New Issue
Block a user