Реализована проверка версионности и требование soft\force обновлений приложения
This commit is contained in:
@@ -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