e0e362d700
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
497 lines
15 KiB
Python
497 lines
15 KiB
Python
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Tests for the tenants package: file provider, HTTP provider, context, and validation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from opensandbox_server.tenants import validate_tenant_config
|
|
from opensandbox_server.tenants.context import get_current_tenant, set_current_tenant
|
|
from opensandbox_server.tenants.file_provider import (
|
|
FileTenantProvider,
|
|
_build_lookup_dict,
|
|
_parse_tenants_file,
|
|
resolve_tenants_path,
|
|
)
|
|
from opensandbox_server.tenants.http_provider import (
|
|
HTTPTenantProvider,
|
|
HTTPTenantProviderConfig,
|
|
)
|
|
from opensandbox_server.tenants.models import TenantEntry
|
|
from opensandbox_server.tenants.provider import TenantProviderUnavailable
|
|
|
|
|
|
SAMPLE_TOML = """\
|
|
[[tenants]]
|
|
name = "team-alpha"
|
|
namespace = "ns-alpha"
|
|
api_keys = ["key-alpha-1", "key-alpha-2"]
|
|
|
|
[[tenants]]
|
|
name = "team-beta"
|
|
namespace = "ns-beta"
|
|
api_keys = ["key-beta-1"]
|
|
"""
|
|
|
|
DUPLICATE_KEY_TOML = """\
|
|
[[tenants]]
|
|
name = "team-a"
|
|
namespace = "ns-a"
|
|
api_keys = ["shared-key"]
|
|
|
|
[[tenants]]
|
|
name = "team-b"
|
|
namespace = "ns-b"
|
|
api_keys = ["shared-key"]
|
|
"""
|
|
|
|
EMPTY_KEYS_TOML = """\
|
|
[[tenants]]
|
|
name = "team-empty"
|
|
namespace = "ns-empty"
|
|
api_keys = []
|
|
"""
|
|
|
|
|
|
# --- context tests ---
|
|
|
|
|
|
def test_context_default_is_none():
|
|
assert get_current_tenant() is None
|
|
|
|
|
|
def test_context_set_and_get():
|
|
entry = TenantEntry(name="t", namespace="ns-t", api_keys=("k",))
|
|
set_current_tenant(entry)
|
|
assert get_current_tenant() is entry
|
|
set_current_tenant(None)
|
|
assert get_current_tenant() is None
|
|
|
|
|
|
# --- resolve_tenants_path ---
|
|
|
|
|
|
def test_resolve_tenants_path_explicit():
|
|
p = resolve_tenants_path("/etc/tenants.toml")
|
|
assert p == Path("/etc/tenants.toml")
|
|
|
|
|
|
def test_resolve_tenants_path_env(monkeypatch):
|
|
monkeypatch.setenv("SANDBOX_TENANTS_CONFIG_PATH", "/tmp/custom.toml")
|
|
assert resolve_tenants_path() == Path("/tmp/custom.toml")
|
|
|
|
|
|
def test_resolve_tenants_path_default(monkeypatch):
|
|
monkeypatch.delenv("SANDBOX_TENANTS_CONFIG_PATH", raising=False)
|
|
p = resolve_tenants_path()
|
|
assert p == Path.home() / ".opensandbox" / "tenants.toml"
|
|
|
|
|
|
# --- _parse_tenants_file / _build_lookup_dict ---
|
|
|
|
|
|
def test_parse_tenants_file(tmp_path):
|
|
f = tmp_path / "tenants.toml"
|
|
f.write_text(SAMPLE_TOML)
|
|
entries = _parse_tenants_file(f)
|
|
assert len(entries) == 2
|
|
assert entries[0].name == "team-alpha"
|
|
assert entries[0].namespace == "ns-alpha"
|
|
assert entries[0].api_keys == ("key-alpha-1", "key-alpha-2")
|
|
assert entries[1].name == "team-beta"
|
|
|
|
|
|
def test_parse_tenants_file_duplicate_key(tmp_path):
|
|
f = tmp_path / "tenants.toml"
|
|
f.write_text(DUPLICATE_KEY_TOML)
|
|
with pytest.raises(ValueError, match="Duplicate api_key"):
|
|
_parse_tenants_file(f)
|
|
|
|
|
|
def test_parse_tenants_file_empty_keys(tmp_path):
|
|
f = tmp_path / "tenants.toml"
|
|
f.write_text(EMPTY_KEYS_TOML)
|
|
with pytest.raises(ValueError, match="no api_keys"):
|
|
_parse_tenants_file(f)
|
|
|
|
|
|
def test_build_lookup_dict():
|
|
entries = [
|
|
TenantEntry(name="a", namespace="ns-a", api_keys=("k1", "k2")),
|
|
TenantEntry(name="b", namespace="ns-b", api_keys=("k3",)),
|
|
]
|
|
lookup = _build_lookup_dict(entries)
|
|
assert lookup["k1"].name == "a"
|
|
assert lookup["k2"].name == "a"
|
|
assert lookup["k3"].name == "b"
|
|
assert "k4" not in lookup
|
|
|
|
|
|
# --- FileTenantProvider ---
|
|
|
|
|
|
def test_file_provider_start_and_lookup(tmp_path):
|
|
f = tmp_path / "tenants.toml"
|
|
f.write_text(SAMPLE_TOML)
|
|
provider = FileTenantProvider(f)
|
|
provider.start()
|
|
try:
|
|
assert provider.ready()
|
|
result = provider.lookup("key-alpha-1")
|
|
assert result is not None
|
|
assert result.namespace == "ns-alpha"
|
|
assert provider.lookup("nonexistent") is None
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_file_provider_list_tenants(tmp_path):
|
|
f = tmp_path / "tenants.toml"
|
|
f.write_text(SAMPLE_TOML)
|
|
provider = FileTenantProvider(f)
|
|
provider.start()
|
|
try:
|
|
tenants = provider.list_tenants()
|
|
assert len(tenants) == 2
|
|
names = {t.name for t in tenants}
|
|
assert names == {"team-alpha", "team-beta"}
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_file_provider_start_file_not_found(tmp_path):
|
|
provider = FileTenantProvider(tmp_path / "missing.toml")
|
|
with pytest.raises(FileNotFoundError):
|
|
provider.start()
|
|
|
|
|
|
def test_file_provider_hot_reload(tmp_path):
|
|
f = tmp_path / "tenants.toml"
|
|
f.write_text(SAMPLE_TOML)
|
|
provider = FileTenantProvider(f)
|
|
provider.start()
|
|
try:
|
|
assert provider.lookup("key-new") is None
|
|
# Modify file
|
|
time.sleep(0.05)
|
|
f.write_text("""\
|
|
[[tenants]]
|
|
name = "team-new"
|
|
namespace = "ns-new"
|
|
api_keys = ["key-new"]
|
|
""")
|
|
provider._reload()
|
|
assert provider.lookup("key-new") is not None
|
|
assert provider.lookup("key-new").namespace == "ns-new"
|
|
assert provider.lookup("key-alpha-1") is None
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_file_provider_reload_bad_file_keeps_previous(tmp_path):
|
|
f = tmp_path / "tenants.toml"
|
|
f.write_text(SAMPLE_TOML)
|
|
provider = FileTenantProvider(f)
|
|
provider.start()
|
|
try:
|
|
f.write_text("invalid toml [[[")
|
|
provider._reload()
|
|
# Should keep previous state
|
|
assert provider.lookup("key-alpha-1") is not None
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_file_provider_reload_deleted_clears(tmp_path):
|
|
f = tmp_path / "tenants.toml"
|
|
f.write_text(SAMPLE_TOML)
|
|
provider = FileTenantProvider(f)
|
|
provider.start()
|
|
try:
|
|
f.unlink()
|
|
provider._reload()
|
|
assert provider.lookup("key-alpha-1") is None
|
|
assert provider.list_tenants() == []
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_file_provider_on_reload_callback(tmp_path):
|
|
f = tmp_path / "tenants.toml"
|
|
f.write_text(SAMPLE_TOML)
|
|
provider = FileTenantProvider(f)
|
|
provider.start()
|
|
calls = []
|
|
provider.on_reload(lambda entries: calls.append(entries))
|
|
try:
|
|
f.write_text(SAMPLE_TOML)
|
|
provider._reload()
|
|
assert len(calls) == 1
|
|
assert len(calls[0]) == 2
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
# --- HTTPTenantProvider ---
|
|
|
|
|
|
def test_http_provider_start_close():
|
|
cfg = HTTPTenantProviderConfig(endpoint="http://localhost:9999/tenants")
|
|
provider = HTTPTenantProvider(cfg)
|
|
assert not provider.ready()
|
|
provider.start()
|
|
assert provider.ready()
|
|
provider.close()
|
|
assert not provider.ready()
|
|
|
|
|
|
def test_http_provider_lookup_cache_hit():
|
|
cfg = HTTPTenantProviderConfig(endpoint="http://localhost:9999/tenants")
|
|
provider = HTTPTenantProvider(cfg)
|
|
provider.start()
|
|
try:
|
|
# Manually inject a cache entry
|
|
from opensandbox_server.tenants.http_provider import _CacheEntry
|
|
|
|
entry = TenantEntry(name="cached", namespace="ns-cached", api_keys=("key-c",))
|
|
provider._cache["key-c"] = _CacheEntry(
|
|
tenant=entry, fetched_at=time.monotonic(), ttl=300
|
|
)
|
|
result = provider.lookup("key-c")
|
|
assert result is not None
|
|
assert result.namespace == "ns-cached"
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_http_provider_lookup_cache_miss_fetch(monkeypatch):
|
|
cfg = HTTPTenantProviderConfig(endpoint="http://localhost:9999/tenants")
|
|
provider = HTTPTenantProvider(cfg)
|
|
provider.start()
|
|
try:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"namespace": "ns-fetched", "ttl": 60}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
provider._client.get = MagicMock(return_value=mock_response)
|
|
result = provider.lookup("key-fetch")
|
|
assert result is not None
|
|
assert result.namespace == "ns-fetched"
|
|
assert "key-fetch" in provider._cache
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_http_provider_lookup_401_returns_none(monkeypatch):
|
|
cfg = HTTPTenantProviderConfig(endpoint="http://localhost:9999/tenants")
|
|
provider = HTTPTenantProvider(cfg)
|
|
provider.start()
|
|
try:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 401
|
|
|
|
provider._client.get = MagicMock(return_value=mock_response)
|
|
result = provider.lookup("bad-key")
|
|
assert result is None
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_http_provider_lookup_network_error_raises_unavailable():
|
|
cfg = HTTPTenantProviderConfig(endpoint="http://localhost:9999/tenants")
|
|
provider = HTTPTenantProvider(cfg)
|
|
provider.start()
|
|
try:
|
|
provider._client.get = MagicMock(side_effect=Exception("connection refused"))
|
|
with pytest.raises(TenantProviderUnavailable):
|
|
provider.lookup("any-key")
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_http_provider_stale_cache_served_on_error():
|
|
cfg = HTTPTenantProviderConfig(
|
|
endpoint="http://localhost:9999/tenants",
|
|
max_stale_seconds=300.0,
|
|
)
|
|
provider = HTTPTenantProvider(cfg)
|
|
provider.start()
|
|
try:
|
|
from opensandbox_server.tenants.http_provider import _CacheEntry
|
|
|
|
entry = TenantEntry(name="stale", namespace="ns-stale", api_keys=("key-s",))
|
|
# TTL expired (fetched 10s ago, TTL=1s) but within max_stale
|
|
provider._cache["key-s"] = _CacheEntry(
|
|
tenant=entry, fetched_at=time.monotonic() - 10, ttl=1
|
|
)
|
|
provider._client.get = MagicMock(side_effect=Exception("timeout"))
|
|
result = provider.lookup("key-s")
|
|
assert result is not None
|
|
assert result.namespace == "ns-stale"
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_http_provider_stale_beyond_max_stale_raises():
|
|
cfg = HTTPTenantProviderConfig(
|
|
endpoint="http://localhost:9999/tenants",
|
|
max_stale_seconds=5.0,
|
|
)
|
|
provider = HTTPTenantProvider(cfg)
|
|
provider.start()
|
|
try:
|
|
from opensandbox_server.tenants.http_provider import _CacheEntry
|
|
|
|
entry = TenantEntry(name="old", namespace="ns-old", api_keys=("key-o",))
|
|
# Fetched 100s ago, TTL=1, max_stale=5 → age(100) > ttl(1)+max_stale(5) → reject
|
|
provider._cache["key-o"] = _CacheEntry(
|
|
tenant=entry, fetched_at=time.monotonic() - 100, ttl=1
|
|
)
|
|
provider._client.get = MagicMock(side_effect=Exception("timeout"))
|
|
with pytest.raises(TenantProviderUnavailable):
|
|
provider.lookup("key-o")
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_http_provider_list_tenants():
|
|
cfg = HTTPTenantProviderConfig(endpoint="http://localhost:9999/tenants")
|
|
provider = HTTPTenantProvider(cfg)
|
|
provider.start()
|
|
try:
|
|
from opensandbox_server.tenants.http_provider import _CacheEntry
|
|
|
|
e1 = TenantEntry(name="t1", namespace="ns-1", api_keys=("k1",))
|
|
e2 = TenantEntry(name="t2", namespace="ns-2", api_keys=("k2",))
|
|
provider._cache["k1"] = _CacheEntry(tenant=e1, fetched_at=time.monotonic(), ttl=60)
|
|
provider._cache["k2"] = _CacheEntry(tenant=e2, fetched_at=time.monotonic(), ttl=60)
|
|
tenants = provider.list_tenants()
|
|
names = {t.name for t in tenants}
|
|
assert names == {"t1", "t2"}
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_http_provider_expired_cache_refresh_success():
|
|
cfg = HTTPTenantProviderConfig(endpoint="http://localhost:9999/tenants")
|
|
provider = HTTPTenantProvider(cfg)
|
|
provider.start()
|
|
try:
|
|
from opensandbox_server.tenants.http_provider import _CacheEntry
|
|
|
|
entry = TenantEntry(name="old", namespace="ns-old", api_keys=("key-r",))
|
|
provider._cache["key-r"] = _CacheEntry(
|
|
tenant=entry, fetched_at=time.monotonic() - 100, ttl=1
|
|
)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"namespace": "ns-refreshed", "ttl": 60}
|
|
mock_response.raise_for_status = MagicMock()
|
|
provider._client.get = MagicMock(return_value=mock_response)
|
|
|
|
result = provider.lookup("key-r")
|
|
assert result is not None
|
|
assert result.namespace == "ns-refreshed"
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_http_provider_expired_401_clears_cache():
|
|
cfg = HTTPTenantProviderConfig(endpoint="http://localhost:9999/tenants")
|
|
provider = HTTPTenantProvider(cfg)
|
|
provider.start()
|
|
try:
|
|
from opensandbox_server.tenants.http_provider import _CacheEntry
|
|
|
|
entry = TenantEntry(name="revoked", namespace="ns-r", api_keys=("key-rev",))
|
|
provider._cache["key-rev"] = _CacheEntry(
|
|
tenant=entry, fetched_at=time.monotonic() - 100, ttl=1
|
|
)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 401
|
|
provider._client.get = MagicMock(return_value=mock_response)
|
|
|
|
result = provider.lookup("key-rev")
|
|
assert result is None
|
|
assert "key-rev" not in provider._cache
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
def test_http_provider_auth_header():
|
|
cfg = HTTPTenantProviderConfig(
|
|
endpoint="http://localhost:9999/tenants",
|
|
auth_header="X-Service-Token",
|
|
auth_token="secret-123",
|
|
)
|
|
provider = HTTPTenantProvider(cfg)
|
|
provider.start()
|
|
try:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"namespace": "ns-auth", "ttl": 30}
|
|
mock_response.raise_for_status = MagicMock()
|
|
provider._client.get = MagicMock(return_value=mock_response)
|
|
|
|
provider.lookup("key-auth")
|
|
call_kwargs = provider._client.get.call_args
|
|
headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers")
|
|
assert headers["X-Service-Token"] == "secret-123"
|
|
assert headers["OPEN-SANDBOX-API-KEY"] == "key-auth"
|
|
finally:
|
|
provider.close()
|
|
|
|
|
|
# --- validate_tenant_config ---
|
|
|
|
|
|
def test_validate_tenant_config_no_tenants():
|
|
cfg = MagicMock()
|
|
cfg.tenants = None
|
|
validate_tenant_config(cfg)
|
|
|
|
|
|
def test_validate_tenant_config_docker_raises():
|
|
cfg = MagicMock()
|
|
cfg.tenants = MagicMock()
|
|
cfg.runtime.type = "docker"
|
|
with pytest.raises(ValueError, match="docker"):
|
|
validate_tenant_config(cfg)
|
|
|
|
|
|
def test_validate_tenant_config_api_key_raises():
|
|
cfg = MagicMock()
|
|
cfg.tenants = MagicMock()
|
|
cfg.runtime.type = "kubernetes"
|
|
cfg.server.api_key = "some-key"
|
|
with pytest.raises(ValueError, match="api_key"):
|
|
validate_tenant_config(cfg)
|
|
|
|
|
|
def test_validate_tenant_config_ok():
|
|
cfg = MagicMock()
|
|
cfg.tenants = MagicMock()
|
|
cfg.runtime.type = "kubernetes"
|
|
cfg.server.api_key = None
|
|
validate_tenant_config(cfg)
|