blob: cdf780e1092857cb262ea0b2cf2f5eb8b44dd958 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
import asyncio
import json
import multiprocessing
import os
import secrets
import stat
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import pytest
from doris_mcp_server.auth.token_manager import TokenManager
from doris_mcp_server.utils.config import DorisConfig
from doris_mcp_server.utils.secret_policy import (
is_static_token_environment_variable,
)
def _config(
token_path: str | Path,
*,
algorithm: str = "sha256",
) -> DorisConfig:
config = DorisConfig()
config.security.token_file_path = str(token_path)
config.security.token_hash_algorithm = algorithm
return config
def _clear_static_token_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
for name in list(os.environ):
if is_static_token_environment_variable(name):
monkeypatch.delenv(name, raising=False)
async def _create_token_in_worker(
token_path: str,
token_id: str,
raw_token: str,
) -> str:
manager = TokenManager(_config(token_path))
try:
await manager.create_token(
token_id,
custom_token=raw_token,
)
return "created"
except ValueError as exc:
return str(exc)
finally:
manager.stop_hot_reload()
def _process_create_token(
token_path: str,
token_id: str,
raw_token: str,
) -> str:
return asyncio.run(_create_token_in_worker(token_path, token_id, raw_token))
async def _revoke_token_in_worker(
token_path: str,
token_id: str,
) -> bool:
manager = TokenManager(_config(token_path))
try:
return await manager.revoke_token(token_id)
finally:
manager.stop_hot_reload()
def _process_revoke_token(token_path: str, token_id: str) -> bool:
return asyncio.run(_revoke_token_in_worker(token_path, token_id))
@pytest.mark.asyncio
async def test_workers_observe_create_and_revoke_on_the_next_access(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_static_token_environment(monkeypatch)
token_path = tmp_path / "tokens.json"
first_worker = TokenManager(_config(token_path, algorithm="sha512"))
second_worker = TokenManager(_config(token_path))
first_worker.hot_reload_interval = 3600
second_worker.hot_reload_interval = 3600
try:
raw_token = await first_worker.create_token("shared-service")
observed = await second_worker.validate_token(raw_token)
assert observed.is_valid is True
assert observed.token_info is not None
assert observed.token_info.token_id == "shared-service"
assert await second_worker.revoke_token("shared-service") is True
rejected = await first_worker.validate_token(raw_token)
assert rejected.is_valid is False
assert rejected.error_message == "Invalid token"
shared_state = json.loads(token_path.read_text(encoding="utf-8"))
assert shared_state["tokens"] == []
assert len(shared_state["revoked_tokens"]) == 1
assert raw_token not in token_path.read_text(encoding="utf-8")
assert stat.S_IMODE(token_path.stat().st_mode) == 0o600
assert stat.S_IMODE(Path(f"{token_path}.lock").stat().st_mode) == 0o600
finally:
first_worker.stop_hot_reload()
second_worker.stop_hot_reload()
@pytest.mark.asyncio
async def test_environment_token_revocation_is_shared_and_persistent(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_static_token_environment(monkeypatch)
raw_token = secrets.token_urlsafe(40)
monkeypatch.setenv("TOKEN_SHARED_ENV", raw_token)
token_path = tmp_path / "tokens.json"
first_worker = TokenManager(_config(token_path, algorithm="sha512"))
second_worker = TokenManager(_config(token_path))
try:
assert (await second_worker.validate_token(raw_token)).is_valid
assert await first_worker.revoke_token("shared_env") is True
assert (await second_worker.validate_token(raw_token)).is_valid is False
restarted_worker = TokenManager(_config(token_path))
try:
assert (await restarted_worker.validate_token(raw_token)).is_valid is False
with pytest.raises(
ValueError,
match="revoked token value",
):
await restarted_worker.create_token(
"replayed",
custom_token=raw_token,
)
finally:
restarted_worker.stop_hot_reload()
stored_text = token_path.read_text(encoding="utf-8")
stored = json.loads(stored_text)
assert stored["tokens"] == []
assert stored["revoked_tokens"][0]["token_id"] == "shared_env"
assert raw_token not in stored_text
finally:
first_worker.stop_hot_reload()
second_worker.stop_hot_reload()
@pytest.mark.asyncio
async def test_concurrent_worker_creates_do_not_lose_updates(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_static_token_environment(monkeypatch)
token_path = tmp_path / "tokens.json"
entries = [(f"worker-{index}", secrets.token_urlsafe(40)) for index in range(8)]
context = multiprocessing.get_context("spawn")
with ProcessPoolExecutor(
max_workers=4,
mp_context=context,
) as executor:
futures = [
executor.submit(
_process_create_token,
str(token_path),
token_id,
raw_token,
)
for token_id, raw_token in entries
]
assert [future.result(timeout=30) for future in futures] == ["created"] * len(
entries
)
manager = TokenManager(_config(token_path))
try:
listed = await manager.list_tokens()
assert {item["token_id"] for item in listed} == {
token_id for token_id, _raw_token in entries
}
for _token_id, raw_token in entries:
assert (await manager.validate_token(raw_token)).is_valid
finally:
manager.stop_hot_reload()
stored_text = token_path.read_text(encoding="utf-8")
stored = json.loads(stored_text)
assert len(stored["tokens"]) == len(entries)
assert all(raw_token not in stored_text for _, raw_token in entries)
@pytest.mark.asyncio
async def test_concurrent_create_and_revoke_preserve_both_updates(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_static_token_environment(monkeypatch)
token_path = tmp_path / "tokens.json"
old_token = secrets.token_urlsafe(40)
new_token = secrets.token_urlsafe(40)
manager = TokenManager(_config(token_path))
try:
await manager.create_token(
"old-service",
custom_token=old_token,
)
finally:
manager.stop_hot_reload()
context = multiprocessing.get_context("spawn")
with ProcessPoolExecutor(
max_workers=2,
mp_context=context,
) as executor:
create_future = executor.submit(
_process_create_token,
str(token_path),
"new-service",
new_token,
)
revoke_future = executor.submit(
_process_revoke_token,
str(token_path),
"old-service",
)
assert create_future.result(timeout=30) == "created"
assert revoke_future.result(timeout=30) is True
verifier = TokenManager(_config(token_path))
try:
assert (await verifier.validate_token(old_token)).is_valid is False
assert (await verifier.validate_token(new_token)).is_valid is True
finally:
verifier.stop_hot_reload()
stored = json.loads(token_path.read_text(encoding="utf-8"))
assert [token["token_id"] for token in stored["tokens"]] == ["new-service"]
assert [revoked["token_id"] for revoked in stored["revoked_tokens"]] == [
"old-service"
]
@pytest.mark.asyncio
async def test_concurrent_duplicate_token_id_has_one_winner(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_static_token_environment(monkeypatch)
token_path = tmp_path / "tokens.json"
entries = [
("same-id", secrets.token_urlsafe(40)),
("same-id", secrets.token_urlsafe(40)),
]
context = multiprocessing.get_context("spawn")
with ProcessPoolExecutor(
max_workers=2,
mp_context=context,
) as executor:
futures = [
executor.submit(
_process_create_token,
str(token_path),
token_id,
raw_token,
)
for token_id, raw_token in entries
]
results = [future.result(timeout=30) for future in futures]
assert results.count("created") == 1
assert sum("already exists" in result for result in results) == 1
stored = json.loads(token_path.read_text(encoding="utf-8"))
assert [token["token_id"] for token in stored["tokens"]] == ["same-id"]