blob: fd844a6d39e88f5c2691cddcbfd535430d1cc2ad [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 json
import os
import stat
from datetime import UTC, datetime
from pathlib import Path
import pytest
from doris_mcp_server.auth.token_manager import DatabaseConfig, TokenManager
from doris_mcp_server.utils.config import (
AuthConfigError,
DorisConfig,
_mark_source,
normalize_effective_auth_config,
)
from doris_mcp_server.utils.secret_policy import (
build_token_digest,
is_static_token_environment_variable,
)
STATIC_TOKEN = "V4nK8qR2mT7xP5cL9sD3hF6jY1uB0eG4iW8aN2zQ"
def _config(tmp_path: Path, *, algorithm: str = "sha256") -> DorisConfig:
config = DorisConfig()
config.security.token_file_path = str(tmp_path / "tokens.json")
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)
@pytest.mark.asyncio
async def test_created_token_is_returned_once_and_persisted_as_digest(
tmp_path,
monkeypatch,
):
_clear_static_token_environment(monkeypatch)
config = _config(tmp_path)
manager = TokenManager(config)
try:
raw_token = await manager.create_token(
"generated",
expires_hours=24,
description="one-time display",
)
token_path = Path(config.security.token_file_path)
token_text = token_path.read_text(encoding="utf-8")
token_data = json.loads(token_text)
stored = token_data["tokens"][0]
assert token_data["version"] == "2.0"
assert raw_token not in token_text
assert "token" not in stored
assert stored["token_digest"].startswith("sha256:")
assert len(stored["token_digest"]) == len("sha256:") + 64
assert stat.S_IMODE(token_path.stat().st_mode) == 0o600
listed = await manager.list_tokens()
assert raw_token not in json.dumps(listed)
assert "token_digest" not in listed[0]
assert (await manager.validate_token(raw_token)).is_valid is True
finally:
manager.stop_hot_reload()
reloaded = TokenManager(config)
try:
result = await reloaded.validate_token(raw_token)
assert result.is_valid is True
assert result.token_info.token_id == "generated"
assert result.token_info.description == "one-time display"
finally:
reloaded.stop_hot_reload()
@pytest.mark.asyncio
async def test_token_database_fe_candidates_round_trip(
tmp_path,
monkeypatch,
):
_clear_static_token_environment(monkeypatch)
config = _config(tmp_path)
manager = TokenManager(config)
try:
raw_token = await manager.create_token(
"multi-fe",
database_config=DatabaseConfig(
host="fe-1.internal",
hosts=["fe-1.internal", "fe-2.internal"],
user="tenant_reader",
password="tenant-password",
database="analytics",
fe_http_hosts=["fe-http-1.internal", "fe-http-2.internal"],
be_hosts=["be-1.internal", "be-2.internal"],
),
)
stored = json.loads(
Path(config.security.token_file_path).read_text(encoding="utf-8")
)["tokens"][0]["database_config"]
assert stored["hosts"] == ["fe-1.internal", "fe-2.internal"]
assert stored["fe_http_hosts"] == [
"fe-http-1.internal",
"fe-http-2.internal",
]
assert stored["be_hosts"] == ["be-1.internal", "be-2.internal"]
selected = manager.get_database_config_by_token(raw_token)
assert selected is not None
assert selected.hosts == ["fe-1.internal", "fe-2.internal"]
assert selected.fe_http_hosts == [
"fe-http-1.internal",
"fe-http-2.internal",
]
finally:
manager.stop_hot_reload()
reloaded = TokenManager(config)
try:
result = await reloaded.validate_token(raw_token)
assert result.is_valid is True
assert result.token_info.token_id == "multi-fe"
assert result.token_info.database_config is not None
assert result.token_info.database_config.hosts == [
"fe-1.internal",
"fe-2.internal",
]
finally:
reloaded.stop_hot_reload()
@pytest.mark.asyncio
async def test_legacy_plaintext_file_is_atomically_migrated_without_expiry_extension(
tmp_path,
monkeypatch,
):
_clear_static_token_environment(monkeypatch)
monkeypatch.setattr(
"doris_mcp_server.auth.token_manager.utc_now",
lambda: datetime(2026, 7, 15, tzinfo=UTC),
)
token_path = tmp_path / "tokens.json"
token_path.write_text(
json.dumps(
{
"version": "1.0",
"token": STATIC_TOKEN,
"tokens": [
{
"token_id": "legacy",
"token": STATIC_TOKEN,
"legacy_raw_copy": STATIC_TOKEN,
"created_at": "2026-07-01T00:00:00Z",
"expires_hours": 720,
"description": "legacy deployment",
"is_active": True,
}
],
}
),
encoding="utf-8",
)
config = _config(tmp_path)
manager = TokenManager(config)
try:
migrated_text = token_path.read_text(encoding="utf-8")
migrated = json.loads(migrated_text)
stored = migrated["tokens"][0]
assert migrated["version"] == "2.0"
assert STATIC_TOKEN not in migrated_text
assert "token" not in stored
assert stored["token_digest"] == build_token_digest(STATIC_TOKEN, "sha256")
assert stored["created_at"] == "2026-07-01T00:00:00Z"
assert stored["expires_at"] == "2026-07-31T00:00:00Z"
assert stat.S_IMODE(token_path.stat().st_mode) == 0o600
assert list(tmp_path.glob(".tokens.json.*.tmp")) == []
finally:
manager.stop_hot_reload()
sha512_default = _config(tmp_path, algorithm="sha512")
reloaded = TokenManager(sha512_default)
try:
result = await reloaded.validate_token(STATIC_TOKEN)
assert result.is_valid is True
assert result.token_info.created_at == datetime(2026, 7, 1, tzinfo=UTC)
assert result.token_info.expires_at == datetime(2026, 7, 31, tzinfo=UTC)
finally:
reloaded.stop_hot_reload()
def test_legacy_plaintext_file_fails_closed_when_migration_cannot_be_written(
tmp_path,
monkeypatch,
):
_clear_static_token_environment(monkeypatch)
token_path = tmp_path / "tokens.json"
token_path.write_text(
json.dumps(
{
"version": "1.0",
"tokens": [
{
"token_id": "legacy",
"token": STATIC_TOKEN,
"is_active": True,
}
],
}
),
encoding="utf-8",
)
monkeypatch.setattr(
TokenManager,
"_atomic_write_token_file",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("read only")),
)
with pytest.raises(ValueError, match="digest-only storage"):
TokenManager(_config(tmp_path))
assert STATIC_TOKEN in token_path.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_digest_file_bootstraps_static_auth_with_embedded_algorithm(
tmp_path,
monkeypatch,
):
_clear_static_token_environment(monkeypatch)
token_path = tmp_path / "tokens.json"
token_path.write_text(
json.dumps(
{
"version": "2.0",
"tokens": [
{
"token_id": "sha512-record",
"token_digest": build_token_digest(STATIC_TOKEN, "sha512"),
"created_at": "2026-07-29T00:00:00Z",
"expires_at": None,
"last_used": None,
"is_active": True,
}
],
}
),
encoding="utf-8",
)
config = _config(tmp_path, algorithm="sha256")
config.security.enable_token_auth = True
_mark_source(config, "enable_token_auth", "test")
normalize_effective_auth_config(config)
manager = TokenManager(config)
try:
assert (await manager.validate_token(STATIC_TOKEN)).is_valid is True
finally:
manager.stop_hot_reload()
@pytest.mark.parametrize(
"entry,error",
[
({"token_id": "missing"}, "exactly one"),
(
{
"token_id": "both",
"token": STATIC_TOKEN,
"token_digest": build_token_digest(STATIC_TOKEN, "sha256"),
},
"exactly one",
),
(
{"token_id": "invalid", "token_digest": "sha256:not-a-digest"},
"64 hexadecimal",
),
],
)
def test_static_auth_rejects_invalid_digest_records(
tmp_path,
monkeypatch,
entry,
error,
):
_clear_static_token_environment(monkeypatch)
token_path = tmp_path / "tokens.json"
token_path.write_text(json.dumps({"tokens": [entry]}), encoding="utf-8")
config = _config(tmp_path)
config.security.enable_token_auth = True
_mark_source(config, "enable_token_auth", "test")
with pytest.raises(AuthConfigError, match=error):
normalize_effective_auth_config(config)
def test_static_auth_rejects_unsupported_digest_algorithm(tmp_path):
config = _config(tmp_path, algorithm="md5")
with pytest.raises(AuthConfigError, match="TOKEN_HASH_ALGORITHM"):
normalize_effective_auth_config(config)
@pytest.mark.asyncio
async def test_create_rolls_back_when_digest_file_cannot_be_persisted(
tmp_path,
monkeypatch,
):
_clear_static_token_environment(monkeypatch)
manager = TokenManager(_config(tmp_path))
monkeypatch.setattr(
manager,
"_atomic_write_token_file",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk full")),
)
try:
with pytest.raises(OSError, match="disk full"):
await manager.create_token("not-persisted", custom_token=STATIC_TOKEN)
assert manager._tokens == {}
assert manager._token_ids == {}
assert manager._digest_algorithms == set()
assert (await manager.validate_token(STATIC_TOKEN)).is_valid is False
finally:
manager.stop_hot_reload()
@pytest.mark.asyncio
async def test_revoke_keeps_live_token_when_digest_file_cannot_be_persisted(
tmp_path,
monkeypatch,
):
_clear_static_token_environment(monkeypatch)
manager = TokenManager(_config(tmp_path))
try:
raw_token = await manager.create_token("still-live")
monkeypatch.setattr(
manager,
"_remove_token_from_file",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("read only")),
)
assert await manager.revoke_token("still-live") is False
assert (await manager.validate_token(raw_token)).is_valid is True
finally:
manager.stop_hot_reload()
@pytest.mark.asyncio
async def test_revoke_and_export_keep_every_record_digest_only(
tmp_path,
monkeypatch,
):
_clear_static_token_environment(monkeypatch)
config = _config(tmp_path)
manager = TokenManager(config)
try:
first = await manager.create_token("first")
second = await manager.create_token("second")
assert await manager.revoke_token("first") is True
primary_text = Path(config.security.token_file_path).read_text(encoding="utf-8")
primary = json.loads(primary_text)
assert first not in primary_text
assert second not in primary_text
assert [entry["token_id"] for entry in primary["tokens"]] == ["second"]
assert set(primary["tokens"][0]) >= {"token_id", "token_digest"}
assert "token" not in primary["tokens"][0]
export_path = tmp_path / "exported-tokens.json"
assert await manager.save_tokens_to_file(str(export_path)) is True
exported_text = export_path.read_text(encoding="utf-8")
exported = json.loads(exported_text)
assert first not in exported_text
assert second not in exported_text
assert exported["version"] == "2.0"
assert "token" not in exported["tokens"][0]
assert stat.S_IMODE(export_path.stat().st_mode) == 0o600
finally:
manager.stop_hot_reload()