Initial commit
This commit is contained in:
commit
5cda7bc309
28 changed files with 1173 additions and 0 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
43
tests/settings.py
Normal file
43
tests/settings.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Minimal Django project, just enough to exercise the kit in isolation."""
|
||||
|
||||
SECRET_KEY = "test-only"
|
||||
DEBUG = True
|
||||
PRODUCTION = False
|
||||
USE_TZ = True
|
||||
|
||||
YKSA_SERVICE = "kit"
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"yksa_kit",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"yksa_kit.middleware.UserTimezoneMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
]
|
||||
|
||||
DATABASES = {
|
||||
"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"},
|
||||
}
|
||||
|
||||
ROOT_URLCONF = "tests.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {"context_processors": [
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"yksa_kit.context_processors.ui_preferences",
|
||||
]},
|
||||
},
|
||||
]
|
||||
|
||||
CACHES = {
|
||||
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
|
||||
}
|
||||
85
tests/test_auth.py
Normal file
85
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.test import RequestFactory
|
||||
|
||||
from yksa_kit.auth import (
|
||||
extract_token,
|
||||
request_can_ingest,
|
||||
request_has_valid_token,
|
||||
resolve_token,
|
||||
store_session_token,
|
||||
)
|
||||
from yksa_kit.models import ApiToken, TokenKind
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rf() -> RequestFactory:
|
||||
return RequestFactory()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_returns_none_when_no_credentials(rf):
|
||||
assert extract_token(rf.get("/api/v1/things/")) is None
|
||||
assert request_has_valid_token(rf.get("/api/v1/things/")) is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_authorization_bearer_header(rf):
|
||||
token = ApiToken.objects.create(name="ci-job")
|
||||
resolved = extract_token(rf.get("/", HTTP_AUTHORIZATION=f"Bearer {token.token}"))
|
||||
assert resolved is not None and resolved.pk == token.pk
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_authorization_raw_header_accepted(rf):
|
||||
"""Plain header value without the 'Bearer ' prefix is tolerated."""
|
||||
token = ApiToken.objects.create(name="legacy-client")
|
||||
assert extract_token(rf.get("/", HTTP_AUTHORIZATION=token.token)) is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_query_string_token(rf):
|
||||
token = ApiToken.objects.create(name="browser")
|
||||
assert extract_token(rf.get("/", {"api_token": token.token})) is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_revoked_token_is_anonymous(rf):
|
||||
token = ApiToken.objects.create(name="revoked", is_active=False)
|
||||
assert extract_token(rf.get("/", HTTP_AUTHORIZATION=f"Bearer {token.token}")) is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unknown_token_is_anonymous(rf):
|
||||
assert extract_token(rf.get("/", HTTP_AUTHORIZATION="Bearer nope")) is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_extract_token_updates_last_used(rf):
|
||||
token = ApiToken.objects.create(name="track-me")
|
||||
assert token.last_used_at is None
|
||||
extract_token(rf.get("/", HTTP_AUTHORIZATION=f"Bearer {token.token}"))
|
||||
token.refresh_from_db()
|
||||
assert token.last_used_at is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_only_ingest_tokens_may_push(rf):
|
||||
read = ApiToken.objects.create(name="reader")
|
||||
ingest = ApiToken.objects.create(name="station", kind=TokenKind.INGEST)
|
||||
assert request_can_ingest(rf.get("/", {"api_token": read.token})) is False
|
||||
assert request_can_ingest(rf.get("/", {"api_token": ingest.token})) is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_session_token_is_ignored_by_the_api_path(rf):
|
||||
"""The session carrier is for browser pages only; an API controller asking
|
||||
request_has_valid_token() must not be authenticated by a stale session."""
|
||||
token = ApiToken.objects.create(name="browser")
|
||||
request = rf.get("/")
|
||||
request.session = {}
|
||||
store_session_token(request, token)
|
||||
|
||||
assert request_has_valid_token(request) is False
|
||||
assert resolve_token(request) is not None
|
||||
51
tests/test_health_and_settings.py
Normal file
51
tests/test_health_and_settings.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.test import Client, override_settings
|
||||
|
||||
from yksa_kit.conf import cache_key, user_agent
|
||||
from yksa_kit.settings import env_bool, env_list, read_secret
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_health_reports_the_database():
|
||||
response = Client().get("/health/")
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.content)["database"] == "connected"
|
||||
|
||||
|
||||
def test_cache_keys_are_namespaced_per_service():
|
||||
"""Two services share one Redis; an unnamespaced key merges their budgets."""
|
||||
assert cache_key("host_rl", "example.com") == "kit:host_rl:example.com"
|
||||
|
||||
|
||||
def test_user_agent_falls_back_to_the_service_slug():
|
||||
assert user_agent().startswith("yksa-kit/")
|
||||
|
||||
|
||||
@override_settings(YKSA_USER_AGENT="yksa-tdas/0.1 (+https://tdas.tmtc.yksa.space)")
|
||||
def test_user_agent_is_overridable():
|
||||
assert "tdas" in user_agent()
|
||||
|
||||
|
||||
def test_env_bool_accepts_the_spellings_compose_files_actually_use(monkeypatch):
|
||||
for raw in ("1", "true", "TRUE", "True", "yes", "on"):
|
||||
monkeypatch.setenv("YKSA_TEST_FLAG", raw)
|
||||
assert env_bool("YKSA_TEST_FLAG") is True
|
||||
monkeypatch.setenv("YKSA_TEST_FLAG", "0")
|
||||
assert env_bool("YKSA_TEST_FLAG") is False
|
||||
monkeypatch.delenv("YKSA_TEST_FLAG")
|
||||
assert env_bool("YKSA_TEST_FLAG", default=True) is True
|
||||
|
||||
|
||||
def test_env_list_splits_and_strips(monkeypatch):
|
||||
monkeypatch.setenv("YKSA_TEST_HOSTS", "a.example, b.example ,")
|
||||
assert env_list("YKSA_TEST_HOSTS") == ["a.example", "b.example"]
|
||||
|
||||
|
||||
def test_read_secret_falls_back_to_the_environment(monkeypatch):
|
||||
monkeypatch.setenv("YKSA_TEST_SECRET", "from-env")
|
||||
assert read_secret("yksa_test_secret") == "from-env"
|
||||
assert read_secret("yksa_absent_secret") is None
|
||||
40
tests/test_host_rate_limit.py
Normal file
40
tests/test_host_rate_limit.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
from yksa_kit import host_rate_limit
|
||||
|
||||
|
||||
def test_host_from_url():
|
||||
assert host_rate_limit.host_from_url("https://Db.SatNOGS.org/api/") == "db.satnogs.org"
|
||||
assert host_rate_limit.host_from_url("not a url") == ""
|
||||
|
||||
|
||||
def test_development_never_blocks():
|
||||
cache.clear()
|
||||
host_rate_limit.acquire("example.com", per_minute=1)
|
||||
host_rate_limit.acquire("example.com", per_minute=1)
|
||||
assert cache.get("kit:host_rl:example.com") is None
|
||||
|
||||
|
||||
@override_settings(PRODUCTION=True)
|
||||
def test_production_counts_against_the_minute_window():
|
||||
cache.clear()
|
||||
host_rate_limit.acquire("example.com", per_minute=5)
|
||||
assert cache.get("kit:host_rl:example.com") == 1
|
||||
|
||||
|
||||
@override_settings(PRODUCTION=True)
|
||||
def test_a_zero_budget_means_unlimited():
|
||||
"""per_minute=0 is how a source says 'no published limit', not 'no requests'."""
|
||||
cache.clear()
|
||||
host_rate_limit.acquire("example.com", per_minute=0)
|
||||
assert cache.get("kit:host_rl:example.com") is None
|
||||
|
||||
|
||||
@override_settings(PRODUCTION=True)
|
||||
def test_the_wait_gives_up_rather_than_stalling_the_worker():
|
||||
cache.clear()
|
||||
cache.set("kit:host_rl:example.com", 99, timeout=60)
|
||||
host_rate_limit.acquire("example.com", per_minute=1, wait_seconds=0.0)
|
||||
23
tests/test_states.py
Normal file
23
tests/test_states.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from yksa_kit.choices import RunStatus
|
||||
from yksa_kit.states import health_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,expected", [
|
||||
("success", "ok"), ("ok", "ok"), ("partial", "warning"), ("failed", "failed"),
|
||||
("error", "failed"), ("running", "running"), ("pending", "running"),
|
||||
("queued", "running"), ("skipped", "skipped"),
|
||||
("", "unknown"), (None, "unknown"), ("something-new", "unknown"),
|
||||
])
|
||||
def test_domain_status_maps_to_one_vocabulary(status, expected):
|
||||
assert health_state(status) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", RunStatus.values)
|
||||
def test_every_run_status_has_a_state(value):
|
||||
"""A new RunStatus member without a mapping would render as `unknown` on every
|
||||
status page, which reads as a bug in the poller rather than a missing table row."""
|
||||
assert health_state(value) != "unknown"
|
||||
3
tests/urls.py
Normal file
3
tests/urls.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [path("", include("yksa_kit.urls"))]
|
||||
Loading…
Add table
Add a link
Reference in a new issue