confluence_web/tests/backend/test_settings.py
Artur Mukhamadiev 5aad7a0b45 backend: admission queue with reservations
In-memory AdmissionController (idle/reserved/running, FIFO tickets keyed by
the session cookie, reservation and heartbeat expiry, promotion after
cleanup, EMA wait estimate) behind POST /api/v1/queue/join,
GET /api/v1/queue/status and DELETE /api/v1/queue/ticket. POST /api/v1/query
claims the session's reservation first and joins implicitly when idle.
Settings: CONFLUENCE_WEB_QUEUE_{RESERVATION_SECONDS,HEARTBEAT_SECONDS,MAX_LENGTH}.
2026-09-15 13:38:59 +03:00

137 lines
5.5 KiB
Python

import pytest
from pydantic import ValidationError
from backend.errors import DestinationDeniedError, InvalidInputError
from backend.settings import Settings, canonicalize_url, validate_confluence_url
def test_canonicalize_url_valid():
assert canonicalize_url("https://approved.example.com") == "https://approved.example.com"
assert canonicalize_url("https://approved.example.com/") == "https://approved.example.com"
assert canonicalize_url("https://approved.example.com:443") == "https://approved.example.com"
assert canonicalize_url("http://approved.example.com:80") == "http://approved.example.com"
assert canonicalize_url("https://approved.example.com:8443/wiki/") == "https://approved.example.com:8443/wiki"
assert canonicalize_url("HTTPS://EXAMPLE.COM/Wiki") == "https://example.com/Wiki"
def test_canonicalize_url_rejects_invalids():
with pytest.raises(InvalidInputError, match="userinfo"):
canonicalize_url("https://user:pass@example.com")
with pytest.raises(InvalidInputError, match="fragment"):
canonicalize_url("https://example.com/#frag")
with pytest.raises(InvalidInputError, match="query"):
canonicalize_url("https://example.com/?query=1")
with pytest.raises(InvalidInputError, match="traversal"):
canonicalize_url("https://example.com/foo/../bar")
with pytest.raises(InvalidInputError, match="traversal"):
canonicalize_url("https://example.com/foo/%2e%2e/bar")
with pytest.raises(InvalidInputError, match="traversal"):
canonicalize_url("https://example.com/%2fetc/passwd")
with pytest.raises(InvalidInputError, match="scheme"):
canonicalize_url("ftp://example.com")
with pytest.raises(InvalidInputError, match="empty"):
canonicalize_url(" ")
# 8 KiB limit
with pytest.raises(InvalidInputError, match="limit"):
canonicalize_url("https://example.com/" + "a" * 8200)
def test_validate_confluence_url():
approved = [
"https://approved.example.com",
"https://corp.example.com/wiki",
]
# Exact match
assert validate_confluence_url("https://approved.example.com", approved) == "https://approved.example.com"
assert validate_confluence_url("https://approved.example.com/", approved) == "https://approved.example.com"
assert validate_confluence_url("https://corp.example.com/wiki", approved) == "https://corp.example.com/wiki"
# Context subpath
assert validate_confluence_url("https://corp.example.com/wiki/sub", approved) == "https://corp.example.com/wiki/sub"
# Reject prefix lookalike: /wikileaks must NOT match /wiki
with pytest.raises(DestinationDeniedError, match="denied"):
validate_confluence_url("https://corp.example.com/wikileaks", approved)
# Reject different host
with pytest.raises(DestinationDeniedError, match="denied"):
validate_confluence_url("https://malicious.example.com", approved)
# Reject different scheme
with pytest.raises(DestinationDeniedError, match="denied"):
validate_confluence_url("http://approved.example.com", approved)
# -- Admission queue settings validators (docs/QUEUE_SPECIFICATION.md section 4) --
def test_queue_reservation_seconds_defaults_and_bounds():
assert Settings().queue_reservation_seconds == 45.0
Settings(queue_reservation_seconds=30.0)
Settings(queue_reservation_seconds=60.0)
with pytest.raises(ValidationError):
Settings(queue_reservation_seconds=29.9)
with pytest.raises(ValidationError):
Settings(queue_reservation_seconds=60.1)
def test_queue_heartbeat_seconds_defaults_and_bounds():
assert Settings().queue_heartbeat_seconds == 15.0
Settings(queue_heartbeat_seconds=5.0)
Settings(queue_heartbeat_seconds=60.0)
with pytest.raises(ValidationError):
Settings(queue_heartbeat_seconds=4.9)
with pytest.raises(ValidationError):
Settings(queue_heartbeat_seconds=60.1)
def test_queue_max_length_defaults_and_bounds():
assert Settings().queue_max_length == 20
Settings(queue_max_length=1)
Settings(queue_max_length=100)
with pytest.raises(ValidationError):
Settings(queue_max_length=0)
with pytest.raises(ValidationError):
Settings(queue_max_length=101)
def test_queue_settings_from_env(monkeypatch):
monkeypatch.setenv("CONFLUENCE_WEB_QUEUE_RESERVATION_SECONDS", "50")
monkeypatch.setenv("CONFLUENCE_WEB_QUEUE_HEARTBEAT_SECONDS", "20")
monkeypatch.setenv("CONFLUENCE_WEB_QUEUE_MAX_LENGTH", "5")
settings = Settings.from_env()
assert settings.queue_reservation_seconds == 50.0
assert settings.queue_heartbeat_seconds == 20.0
assert settings.queue_max_length == 5
def test_max_deadline_seconds_defaults_and_bounds():
assert Settings().max_deadline_seconds == 900.0
Settings(max_deadline_seconds=60.0, query_timeout_seconds=60.0)
Settings(max_deadline_seconds=3600.0)
with pytest.raises(ValidationError):
Settings(max_deadline_seconds=59.0, query_timeout_seconds=59.0)
with pytest.raises(ValidationError):
Settings(max_deadline_seconds=3601.0)
def test_query_timeout_must_not_exceed_max_deadline():
Settings(query_timeout_seconds=900.0, max_deadline_seconds=900.0)
with pytest.raises(ValidationError, match="must not exceed"):
Settings(query_timeout_seconds=901.0, max_deadline_seconds=900.0)
def test_max_deadline_seconds_from_env(monkeypatch):
monkeypatch.setenv("CONFLUENCE_WEB_MAX_DEADLINE_SECONDS", "1200")
monkeypatch.setenv("CONFLUENCE_WEB_QUERY_TIMEOUT_SECONDS", "1000")
assert Settings.from_env().max_deadline_seconds == 1200.0