Merge pull request 'fix(api): CORS открывает фронту Content-Disposition (#173, AURORA-1532)' (#174) from feat/173-cors-expose-content-disposition into main
This commit is contained in:
commit
f2c70935b2
@ -1004,7 +1004,7 @@ email без учёта регистра проверяет только сер
|
|||||||
| `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно |
|
| `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно |
|
||||||
| `DB_SCHEMA` | `dkb` | схема PostgreSQL модели данных: MetaData ORM, `search_path` движка приложения, `CREATE SCHEMA` и таблица версий Alembic, схема в миграциях. Имя — строчный идентификатор `[a-z_][a-z0-9_]{0,62}`, не `pg_*` (подставляется в DDL без кавычек; иное — ошибка при старте). **Одно значение для приложения и для `alembic upgrade`:** сменить схему у уже мигрированной БД нельзя (таблицы остаются в старой); в контуре — `OPENBAO__APP__DB_SCHEMA`, в миграционном задании тоже. Читается при импорте моделей, поэтому скрипты и тесты видят его через те же `.env`/окружение |
|
| `DB_SCHEMA` | `dkb` | схема PostgreSQL модели данных: MetaData ORM, `search_path` движка приложения, `CREATE SCHEMA` и таблица версий Alembic, схема в миграциях. Имя — строчный идентификатор `[a-z_][a-z0-9_]{0,62}`, не `pg_*` (подставляется в DDL без кавычек; иное — ошибка при старте). **Одно значение для приложения и для `alembic upgrade`:** сменить схему у уже мигрированной БД нельзя (таблицы остаются в старой); в контуре — `OPENBAO__APP__DB_SCHEMA`, в миграционном задании тоже. Читается при импорте моделей, поэтому скрипты и тесты видят его через те же `.env`/окружение |
|
||||||
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) |
|
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) |
|
||||||
| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны) |
|
| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны). В обоих режимах фронту открыт заголовок `Content-Disposition` (`Access-Control-Expose-Headers`) — имя файла экспорта (#173) |
|
||||||
| `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) |
|
| `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) |
|
||||||
| `ROOT_PATH` | `/` | корневой путь за прокси (должен совпадать с `REACT_APP_ROOT_PATH` фронта) |
|
| `ROOT_PATH` | `/` | корневой путь за прокси (должен совпадать с `REACT_APP_ROOT_PATH` фронта) |
|
||||||
| `VERSION` | `1.0.0` | версия в `/healthcheck` и OpenAPI |
|
| `VERSION` | `1.0.0` | версия в `/healthcheck` и OpenAPI |
|
||||||
|
|||||||
@ -203,19 +203,24 @@ async def lifespan(app: FastAPI):
|
|||||||
await _shutdown(app)
|
await _shutdown(app)
|
||||||
|
|
||||||
|
|
||||||
|
#: заголовки ответа, которые браузер отдаёт JS с другого источника: имя файла экспорта (xlsx, csv, скан)
|
||||||
|
EXPOSE_HEADERS = ["Content-Disposition"]
|
||||||
|
|
||||||
|
|
||||||
def cors_options(app_settings: Settings) -> dict[str, Any] | None:
|
def cors_options(app_settings: Settings) -> dict[str, Any] | None:
|
||||||
"""Источники CORS (OQ-65): DEBUG — `*` без credentials (ревью R-11; токен — в заголовке, cookie не нужны);
|
"""Источники CORS (OQ-65): DEBUG — `*` без credentials (ревью R-11; токен — в заголовке, cookie не нужны);
|
||||||
иначе — `CORS_ORIGINS`.
|
иначе — `CORS_ORIGINS`.
|
||||||
|
|
||||||
Пустой `CORS_ORIGINS` вне DEBUG — `None`: middleware не подключается, браузер кросс-доменные запросы не выполнит.
|
Пустой `CORS_ORIGINS` вне DEBUG — `None`: middleware не подключается, браузер кросс-доменные запросы не выполнит.
|
||||||
`allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials).
|
`allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials).
|
||||||
|
`Content-Disposition` открыт фронту: без него имя файла экспорта с другого источника недоступно (arm-core #173).
|
||||||
"""
|
"""
|
||||||
if app_settings.DEBUG:
|
if app_settings.DEBUG:
|
||||||
return {"allow_origins": ["*"], "allow_credentials": False}
|
return {"allow_origins": ["*"], "allow_credentials": False, "expose_headers": EXPOSE_HEADERS}
|
||||||
origins = list(app_settings.CORS_ORIGINS)
|
origins = list(app_settings.CORS_ORIGINS)
|
||||||
if not origins:
|
if not origins:
|
||||||
return None
|
return None
|
||||||
return {"allow_origins": origins, "allow_credentials": "*" not in origins}
|
return {"allow_origins": origins, "allow_credentials": "*" not in origins, "expose_headers": EXPOSE_HEADERS}
|
||||||
|
|
||||||
|
|
||||||
#: пути документации API: при DEBUG=false маршрутов нет, а SPA-заглушка отвечает на них 404, а не index.html
|
#: пути документации API: при DEBUG=false маршрутов нет, а SPA-заглушка отвечает на них 404, а не index.html
|
||||||
|
|||||||
@ -642,20 +642,31 @@ async def test_cors_prod_wildcard_without_credentials(tmp_path: Any) -> None:
|
|||||||
assert "access-control-allow-credentials" not in response.headers
|
assert "access-control-allow-credentials" not in response.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("update", [{"DEBUG": True}, {"DEBUG": False, "CORS_ORIGINS": [ORIGIN]}])
|
||||||
|
async def test_cors_exposes_content_disposition(tmp_path: Any, update: dict[str, Any]) -> None:
|
||||||
|
"""Фронт на другом источнике читает имя файла экспорта из Content-Disposition (qa-guide W35, arm-core #173)."""
|
||||||
|
response = await _request(_app_with(tmp_path, **update), "GET", "/healthcheck", headers={"Origin": ORIGIN})
|
||||||
|
|
||||||
|
assert response.headers["access-control-expose-headers"] == "Content-Disposition"
|
||||||
|
|
||||||
|
|
||||||
def test_cors_options_rules() -> None:
|
def test_cors_options_rules() -> None:
|
||||||
base = app_module.settings
|
base = app_module.settings
|
||||||
assert app_module.cors_options(base.model_copy(update={"DEBUG": True, "CORS_ORIGINS": []})) == {
|
assert app_module.cors_options(base.model_copy(update={"DEBUG": True, "CORS_ORIGINS": []})) == {
|
||||||
"allow_origins": ["*"],
|
"allow_origins": ["*"],
|
||||||
"allow_credentials": False,
|
"allow_credentials": False,
|
||||||
|
"expose_headers": ["Content-Disposition"],
|
||||||
}
|
}
|
||||||
assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": []})) is None
|
assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": []})) is None
|
||||||
assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": [ORIGIN]})) == {
|
assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": [ORIGIN]})) == {
|
||||||
"allow_origins": [ORIGIN],
|
"allow_origins": [ORIGIN],
|
||||||
"allow_credentials": True,
|
"allow_credentials": True,
|
||||||
|
"expose_headers": ["Content-Disposition"],
|
||||||
}
|
}
|
||||||
assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": ["*", ORIGIN]})) == {
|
assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": ["*", ORIGIN]})) == {
|
||||||
"allow_origins": ["*", ORIGIN],
|
"allow_origins": ["*", ORIGIN],
|
||||||
"allow_credentials": False,
|
"allow_credentials": False,
|
||||||
|
"expose_headers": ["Content-Disposition"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user