hermes-hub/tests/test_a37_isolation_guards.py
ochenstarik-ui eac8352dc2 fix(release,web): ворота публикации перестали пропускать всё подряд, /api/action — межсайтовые запросы
P0 аудита, каждый сначала подтверждён исполнением, а не принят со слов.

1. Release Gate объявлял проверку хеша, которой не было. Печаталась строка
   PACKAGE_HASH_VERIFIED=True при том, что hashlib в scripts/release_gate.py
   не вызывался ни разу: скачивались байты 0-10 через заголовок Range, и
   этого хватало, чтобы счесть хеш проверенным. «Проверенным ассетом» при
   этом оказывался первый в списке — checksums.txt, а не пакет.

2. Ворота публикации были fail-open. Измерено в трёх условиях: полный обрыв
   сети -> PASS, манифест 404 -> PASS, пакет 404 -> PASS. Ворота пропускали
   релиз при любом исходе, включая полное отсутствие релиза.

   Разделено на офлайновую часть (проверки 1-7: версии, тесты, updater,
   статика, секреты, список разрешённых адресов) и Publication Gate: релиз
   есть, ассеты есть, пакет скачан ЦЕЛИКОМ, SHA-256 сошёлся с опубликованным
   checksums.txt. Публикационные ворота блокируют в режиме публикации
   (--publication или HERMES_RELEASE_PUBLICATION_GATE=1); в обычном прогоне
   CI, где релиза для ветки нет и быть не должно, результат сообщается как
   есть и не блокирует. Неизмеренное называется причиной, а не выдаётся за
   проверенное. Проверено на живом релизе v0.1.3-b1: два пакета скачаны
   целиком, хеши сошлись.

3. POST /api/action на loopback принимал межсайтовые запросы. Токен там не
   требуется, а действие меняет состояние: удаляет учётные данные, чистит
   аккаунты, переключает маршрутизацию, запускает входы OAuth. CORS от этого
   не защищает — он мешает прочитать ответ, а не отправить запрос. Измерено
   на конфигурации по умолчанию: POST с Content-Type text/plain уходит
   кросс-сайтом без предварительного запроса, request.json() разбирает тело
   независимо от Content-Type, и запрос с Origin чужого сайта без токена
   доходил до исполнителя действий.

   Проверяется Sec-Fetch-Site, при его отсутствии — Origin против адреса
   запроса. Собственный интерфейс, адресная строка и не-браузерные клиенты
   работают как раньше. Защита распространена на все пять небезопасных
   методов, не только на /api/action.

4. pricing fallback: safe_load вместо safe_dump. dump сериализовал текст
   обратно в строку, проверка isinstance(data, dict) не выполнялась никогда,
   таблица цен не загружалась ни разу, а except это глушил.

5. Симуляция Linux в тесте stop_running_hub падала на Windows: os.getuid там
   не существует.

Тесты: 756 -> 776 passed, 2 skipped, 4 deselected. ruff check . чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00

545 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Test suite for Task A37: Agent Isolation, Credential Protection, Workspace Boundaries, and Forensic Audit."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.router.security_guard import (
WorkspaceBoundaryGuard,
NetworkBoundaryGuard,
AgentWorkspaceGuard,
BoundaryViolationError,
NetworkBoundaryViolationError,
scrub_secrets,
scrub_string,
get_workspace_guard,
)
from antigravity_provider.router.unified_health import EventLogService
from antigravity_provider.router.action_handler import ActionExecutor, do_delete_credentials
from antigravity_provider.router.web.server import app
# ═══════════════════════════════════════════════════════════════
# P0-1: Workspace Boundary & Destructive Operations Guard Tests
# ═══════════════════════════════════════════════════════════════
class TestWorkspaceBoundaryGuard:
"""Tests for workspace boundaries, traversal prevention, and credential defense."""
def test_deletion_outside_allowed_workspace_is_blocked(self, tmp_path):
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path / "allowed_workspace"])
(tmp_path / "allowed_workspace").mkdir(parents=True, exist_ok=True)
outside_file = tmp_path / "outside_workspace" / "sensitive_file.txt"
outside_file.parent.mkdir(parents=True, exist_ok=True)
outside_file.write_text("critical data", encoding="utf-8")
ok, reason, safe_alt = guard.validate_path(outside_file, operation="delete")
assert ok is False
assert "за пределами разрешённых рабочих областей" in reason
assert safe_alt is not None
with pytest.raises(BoundaryViolationError):
guard.safe_delete_file(outside_file)
def test_unconditional_protection_of_credential_pools_and_ssh(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[hermes_home])
# 1. agy_profiles
agy_profile = hermes_home / "agy_profiles" / "ag-w1" / "auth.json"
agy_profile.parent.mkdir(parents=True, exist_ok=True)
agy_profile.write_text("token", encoding="utf-8")
ok, reason, alt = guard.validate_path(agy_profile, operation="delete")
assert ok is False
assert "защищённого каталога учётных данных" in reason or "защищённым" in reason
assert "delete_credentials" in alt
# 2. hub_settings.json
settings_p = hermes_home / "hub_settings.json"
settings_p.write_text("{}", encoding="utf-8")
ok, reason, _ = guard.validate_path(settings_p, operation="delete")
assert ok is False
# 3. .ssh
fake_ssh = Path.home() / ".ssh" / "id_rsa"
ok, reason, _ = guard.validate_path(fake_ssh, operation="delete")
assert ok is False
def test_path_traversal_and_symlink_bypass_prevention(self, tmp_path):
ws_dir = tmp_path / "workspace"
ws_dir.mkdir(parents=True, exist_ok=True)
secret_outside = tmp_path / "secret.txt"
secret_outside.write_text("secret", encoding="utf-8")
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[ws_dir])
# Relative path traversal attack: workspace/../../secret.txt
traversal_path = ws_dir / "subdir" / ".." / ".." / "secret.txt"
ok, reason, _ = guard.validate_path(traversal_path, operation="delete")
assert ok is False
assert "за пределами" in reason
# Symlink attack: symlink inside workspace pointing outside
link_inside = ws_dir / "link_to_outside"
try:
link_inside.symlink_to(secret_outside)
ok_symlink, reason_symlink, _ = guard.validate_path(link_inside, operation="delete")
assert ok_symlink is False
assert "за пределами" in reason_symlink
except OSError:
# On platforms without symlink privileges, skip symlink creation
pass
def test_shell_command_analysis_and_classification(self, tmp_path):
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path / "project"])
project_dir = tmp_path / "project"
project_dir.mkdir(parents=True, exist_ok=True)
# 1. Dangerous rm -rf / or outside
ok, reason, _ = guard.validate_command("rm -rf /etc/passwd", cwd=project_dir)
assert ok is False
# 2. Windows del C:\Windows
ok, reason, _ = guard.validate_command("del /f /q C:\\Windows\\System32", cwd=project_dir)
assert ok is False
# 3. Safe rm within project
ok, reason, _ = guard.validate_command("rm temp_file.log", cwd=project_dir)
assert ok is True
def test_dry_run_deletion_reports_metadata_without_deleting(self, tmp_path):
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path])
test_dir = tmp_path / "to_delete"
test_dir.mkdir(parents=True, exist_ok=True)
file1 = test_dir / "file1.txt"
file2 = test_dir / "file2.txt"
file1.write_text("hello 1", encoding="utf-8")
file2.write_text("hello 222", encoding="utf-8")
res = guard.dry_run_deletion([test_dir])
assert res["dry_run"] is True
assert res["allowed"] is True
assert res["total_dirs"] >= 1
assert res["total_files"] == 0 # directory item
assert res["total_bytes"] == len("hello 1".encode()) + len("hello 222".encode())
assert file1.exists()
assert file2.exists()
# ═══════════════════════════════════════════════════════════════
# P0-2: Agent Isolation & Credential Separation Tests
# ═══════════════════════════════════════════════════════════════
class TestAgentIsolation:
"""Tests for dedicated agent workspaces, role-scoped credentials, and actor attribution."""
def test_dedicated_agent_workspace_creation(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
ws_dev1 = AgentWorkspaceGuard.get_agent_workspace_dir("developer-1")
ws_rev = AgentWorkspaceGuard.get_agent_workspace_dir("code-reviewer")
assert ws_dev1.is_dir()
assert ws_rev.is_dir()
assert ws_dev1 != ws_rev
assert "agent-developer-1" in str(ws_dev1)
assert "agent-code-reviewer" in str(ws_rev)
def test_role_scoped_credential_isolation(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
env_dev1 = AgentWorkspaceGuard.build_agent_subprocess_env(
agent_id="developer-1",
role="coder",
assigned_profile_id="ag-w1",
)
assert env_dev1["HERMES_AGENT_ID"] == "developer-1"
assert env_dev1["HERMES_AGENT_ROLE"] == "coder"
assert "ag-w1" in env_dev1["HOME"]
assert "ag-w1" in env_dev1["USERPROFILE"]
# Ensure no cross-agent foreign API keys leak
assert "ANTHROPIC_API_KEY" not in env_dev1
assert "OPENAI_API_KEY" not in env_dev1
def test_actor_attribution_in_action_executor(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
EventLogService._instance = None
event_svc = EventLogService.get()
res = ActionExecutor.execute(
action="dry_run_delete",
data={"paths": [str(tmp_path)]},
actor="agent:developer-1",
)
assert res["ok"] is True
events = event_svc.get_events(limit=5)
actor_events = [e for e in events if e.actor == "agent:developer-1"]
assert len(actor_events) > 0
assert actor_events[0].action == "dry_run_delete"
assert actor_events[0].outcome == "dry_run"
# ═══════════════════════════════════════════════════════════════
# P0-3: Network Boundary Guard & CORS Tests
# ═══════════════════════════════════════════════════════════════
class TestNetworkBoundaryAndCORS:
"""Tests for outbound destination whitelist, strict CORS, and network HTTP disclosures."""
def test_outbound_destination_whitelist(self):
# Allowed AI providers & releases
assert NetworkBoundaryGuard.is_host_allowed("https://api.anthropic.com/v1/messages") is True
assert NetworkBoundaryGuard.is_host_allowed("https://generativelanguage.googleapis.com") is True
assert NetworkBoundaryGuard.is_host_allowed("https://api.github.com/repos/releases") is True
assert NetworkBoundaryGuard.is_host_allowed("http://127.0.0.1:11434/api/tags") is True
assert NetworkBoundaryGuard.is_host_allowed("http://localhost:8000/v1/models") is True
# Blocked external / rogue hosts
assert NetworkBoundaryGuard.is_host_allowed("https://evil-hacker.com/exfil") is False
assert NetworkBoundaryGuard.is_host_allowed("http://internal-artifactory.local:8081") is False
with pytest.raises(NetworkBoundaryViolationError):
NetworkBoundaryGuard.validate_outbound_url("http://internal-artifactory.local/coordination")
def test_cors_rejects_wildcard_with_credentials(self, client):
response = client.options(
"/api/snapshot",
headers={"Origin": "https://attacker.example.com", "Access-Control-Request-Method": "GET"},
)
# Should NOT return allow-origin: * or echo back attacker origin
allow_origin = response.headers.get("access-control-allow-origin")
assert allow_origin != "*"
assert allow_origin != "https://attacker.example.com"
def test_network_http_warning_on_external_bind(self, client, monkeypatch):
from antigravity_provider.router import settings_service
fake_settings = {
"web_api_host": "0.0.0.0",
"web_api_token": "secret_token_123",
}
monkeypatch.setattr(settings_service, "get_hub_settings", lambda: fake_settings)
res = client.get("/api/settings", headers={"X-Hub-Token": "secret_token_123"})
assert res.status_code == 200
data = res.json()
assert "network_security" in data
assert data["network_security"]["is_external_bind"] is True
assert "открытого HTTP" in data["network_security"]["warning"]
# ═══════════════════════════════════════════════════════════════
# P0-4: Forensic Audit Logging & Secret Scrubbing Tests
# ═══════════════════════════════════════════════════════════════
class TestForensicAuditAndSecretScrubbing:
"""Tests for secret scrubbing and forensic traceability."""
def test_secret_scrubbing_removes_tokens_and_bearer_headers(self):
dirty = {
"user_prompt": "Please summarize this commit",
"auth_header": "Bearer sk-proj-1234567890abcdef123456",
"api_key": "secret_api_key_value",
"nested": {
"token": "ghp_12345678901234567890",
"safe_field": "visible value",
},
}
clean = scrub_secrets(dirty)
assert clean["api_key"] == "***"
assert clean["nested"]["token"] == "***"
assert clean["nested"]["safe_field"] == "visible value"
assert "sk-proj" not in clean["auth_header"]
def test_string_scrubber_masks_inline_secrets(self):
msg = "Request failed with error: Bearer ghp_abcdef1234567890 on host https://api.openai.com?access_token=sk-9876543210"
scrubbed = scrub_string(msg)
assert "ghp_abcdef" not in scrubbed
assert "sk-987654" not in scrubbed
assert "Bearer ***" in scrubbed
assert "access_token=***" in scrubbed
def test_audit_log_captures_actor_and_outcome(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
EventLogService._instance = None
svc = EventLogService.get()
svc.log(
category="account",
message="Удаление профиля grok-worker-1",
actor="reviewer:manual",
action="delete_credentials",
target_profile="grok-worker-1",
target_role="code-reviewer",
outcome="success",
level="warning",
)
events = svc.get_events(limit=5)
assert len(events) >= 1
ev = events[0]
assert ev.actor == "reviewer:manual"
assert ev.action == "delete_credentials"
assert ev.target_profile == "grok-worker-1"
assert ev.target_role == "code-reviewer"
assert ev.outcome == "success"
# ═══════════════════════════════════════════════════════════════
# P0-5: Live Deletion Confirmation & Safe Operations Tests
# ═══════════════════════════════════════════════════════════════
class TestCredentialsDeletionGuard:
"""Tests for explicit credential deletion confirmation and dry-run."""
def test_delete_credentials_dry_run(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from antigravity_provider.router.profile_manager import get_profile_auth_path
auth_p = get_profile_auth_path("grok", "grok-worker-1")
auth_p.parent.mkdir(parents=True, exist_ok=True)
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
res = ActionExecutor.execute(
action="delete_credentials",
data={"provider": "grok", "profile_id": "grok-worker-1", "dry_run": True},
actor="reviewer:manual",
)
assert res["ok"] is True
assert res.get("dry_run") is True
assert auth_p.is_file() # File MUST NOT be deleted during dry-run
def test_delete_credentials_unconfirmed_request(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from antigravity_provider.router.profile_manager import get_profile_auth_path
auth_p = get_profile_auth_path("grok", "grok-worker-1")
auth_p.parent.mkdir(parents=True, exist_ok=True)
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
res = ActionExecutor.execute(
action="delete_credentials",
data={"provider": "grok", "profile_id": "grok-worker-1", "confirmed": False},
actor="agent:developer-1",
)
assert res["ok"] is False
assert res.get("confirmation_required") is True
assert auth_p.is_file() # File MUST NOT be deleted without confirmation
def test_delete_credentials_confirmed_success(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from antigravity_provider.router.profile_manager import get_profile_auth_path
auth_p = get_profile_auth_path("grok", "grok-worker-1")
auth_p.parent.mkdir(parents=True, exist_ok=True)
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
res = ActionExecutor.execute(
action="delete_credentials",
data={"provider": "grok", "profile_id": "grok-worker-1", "confirmed": True},
actor="user:web",
)
assert res["ok"] is True
assert not auth_p.is_file()
@pytest.fixture
def client():
return TestClient(app)
def test_destructive_command_with_tilde_is_rejected(monkeypatch, tmp_path):
"""Команда с тильдой не должна обходить защиту каталогов учётных данных.
validate_command не раскрывал "~" и "$HOME" перед проверкой. Путь
"~/.hermes/agy_profiles" не считался абсолютным, склеивался с каталогом
проекта в путь с буквальным "~" внутри и признавался допустимым.
Измерено на реализации: "rm -rf ~/.hermes/agy_profiles" проходило, а та же
команда с абсолютным путём отклонялась. То есть самый естественный способ
написать опасную команду обходил защиту ровно в том месте, ради которого
она и делалась.
"""
from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
guard = WorkspaceBoundaryGuard()
must_reject = [
"rm -rf ~/.hermes/agy_profiles",
"rm -rf ~/.ssh",
"rm -rf $HOME/.hermes",
]
for cmd in must_reject:
allowed, reason, _alt = guard.validate_command(cmd)
assert not allowed, f"команда с тильдой прошла мимо защиты: {cmd} ({reason})"
# Обычная работа внутри проекта не должна страдать.
allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py")
assert allowed, "защита мешает штатной работе внутри проекта"
# ── HUB-1: граница обязана держаться одинаково на Windows и Linux ──
#
# Инвариант A37 падал только на Windows-раннере: "rm -rf $HOME/.hermes"
# проходил мимо защиты, потому что переменной HOME в окружении Windows нет.
# Прогон на одной системе этого не показывал. Тесты ниже воспроизводят
# окружение обеих систем на любой из них, поэтому дыра больше не может
# спрятаться за тем, где именно запущен CI.
_DESTRUCTIVE_BOTH_DIALECTS = [
"rm -rf ~/.hermes/agy_profiles",
"rm -rf ~/.ssh",
"rm -rf $HOME/.hermes",
"rm -rf ${HOME}/.hermes",
r"rm -rf %USERPROFILE%\.hermes",
r"del /f /q %USERPROFILE%\.ssh",
]
@pytest.fixture
def guard_env(monkeypatch, tmp_path):
"""Guard с HERMES_HOME во временном каталоге — вне домашнего и вне проекта."""
from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
return WorkspaceBoundaryGuard()
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
@pytest.mark.parametrize("cmd", _DESTRUCTIVE_BOTH_DIALECTS)
def test_home_directed_destruction_rejected_on_both_systems(monkeypatch, guard_env, simulated_os, cmd):
"""Удаление по домашнему каталогу отклоняется в обоих окружениях.
Окружение Windows отличается от Linux ровно тем, из-за чего защита и
расходилась: HOME не задан, домашний каталог известен через USERPROFILE.
"""
if simulated_os == "windows":
monkeypatch.delenv("HOME", raising=False)
monkeypatch.setenv("USERPROFILE", str(Path.home()))
else:
monkeypatch.setenv("HOME", str(Path.home()))
monkeypatch.delenv("USERPROFILE", raising=False)
allowed, reason, _alt = guard_env.validate_command(cmd)
assert not allowed, f"[{simulated_os}] команда прошла мимо защиты: {cmd} ({reason})"
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
def test_guard_fails_closed_on_unresolvable_argument(monkeypatch, guard_env, simulated_os):
"""Нераскрытая переменная — отказ, а не пропуск.
Раньше "$HOME/.hermes" с неизвестной переменной переставал быть абсолютным
путём, склеивался с каталогом проекта и признавался допустимым. Путь,
который нельзя разрешить, нельзя и признать безопасным.
"""
if simulated_os == "windows":
monkeypatch.delenv("HOME", raising=False)
monkeypatch.delenv("HERMES_UNSET_TARGET", raising=False)
for cmd in ["rm -rf $HERMES_UNSET_TARGET/data", r"rm -rf %HERMES_UNSET_TARGET%\data"]:
allowed, reason, _alt = guard_env.validate_command(cmd)
assert not allowed, f"[{simulated_os}] guard не закрылся на нераскрытом пути: {cmd} ({reason})"
def test_foreign_dialect_absolute_path_is_not_treated_as_project_local(guard_env):
"""Путь с буквой диска на Linux не должен считаться внутренним.
Path("C:/Windows").resolve() на Linux приписывает пути текущий каталог, и
удаление системного каталога Windows выглядело как работа внутри проекта.
"""
allowed, reason, _alt = guard_env.validate_command(r"del /f /q C:\Windows\System32")
assert not allowed, f"путь чужого диалекта признан внутренним: {reason}"
def test_normal_work_inside_project_still_allowed(guard_env):
"""Ужесточение не должно мешать штатной работе."""
for cmd in ["rm src/temp_file.py", "rm -rf build/", "rm ./tests/tmp.log"]:
allowed, reason, _alt = guard_env.validate_command(cmd)
assert allowed, f"защита мешает штатной работе: {cmd} ({reason})"
# ── HUB-1: небезопасные методы не принимаются с чужой страницы ──
@pytest.fixture
def loopback_client():
"""Клиент при конфигурации по умолчанию: web_api_host=127.0.0.1, токен не нужен."""
import antigravity_provider.router.web.server as srv
with patch.object(srv, "_web_settings", return_value={"web_api_host": "127.0.0.1"}):
yield TestClient(app)
_UNSAFE_ENDPOINTS = [
("/api/action", '{"action": "clear_accounts", "data": {}}'),
("/api/skills/assign", '{"skill": "x", "profile": "y"}'),
("/api/skills/unassign", '{"skill": "x", "profile": "y"}'),
("/api/skills/diagnose", '{"skill": "x"}'),
("/api/compression/test", '{"text": "x"}'),
]
@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS)
def test_cross_site_post_is_rejected(loopback_client, path, body):
"""Межсайтовый POST отклоняется до того, как что-либо изменит.
На loopback токен не требуется, а действия меняют состояние: удаляют
учётные данные, чистят аккаунты, переключают маршрутизацию. CORS от этого
не защищает — он мешает прочитать ответ, а не отправить запрос. Измерено:
POST с Content-Type text/plain уходит кросс-сайтом без предварительного
запроса, request.json() разбирает тело независимо от Content-Type, и
запрос с чужим Origin доходил до исполнителя действий.
"""
headers = {
"Content-Type": "text/plain;charset=UTF-8",
"Origin": "https://evil.example.com",
"Sec-Fetch-Site": "cross-site",
}
res = loopback_client.post(path, content=body, headers=headers)
assert res.status_code == 403, f"{path} принял межсайтовый запрос: HTTP {res.status_code}"
@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS)
def test_cross_site_post_rejected_without_fetch_metadata(loopback_client, path, body):
"""Браузер без Sec-Fetch-* всё равно ставит Origin — по нему и отклоняем."""
headers = {"Content-Type": "text/plain", "Origin": "https://evil.example.com"}
res = loopback_client.post(path, content=body, headers=headers)
assert res.status_code == 403, f"{path} принял запрос с чужим Origin: HTTP {res.status_code}"
@pytest.mark.parametrize(
"label, headers",
[
("собственный интерфейс", {"Origin": "http://testserver", "Sec-Fetch-Site": "same-origin"}),
("адресная строка", {"Sec-Fetch-Site": "none"}),
("не-браузерный клиент", {}),
],
)
def test_own_interface_and_cli_still_work(loopback_client, label, headers):
"""Защита не должна мешать собственному интерфейсу и не-браузерным клиентам."""
res = loopback_client.post(
"/api/action",
json={"action": "___нет_такого___", "data": {}},
headers=headers,
)
assert res.status_code != 403, f"{label} отклонён межсайтовой защитой"