315 lines
12 KiB
Python
315 lines
12 KiB
Python
|
|
"""
|
||
|
|
Tests for LOCAL_MODE behavior.
|
||
|
|
|
||
|
|
Verifies:
|
||
|
|
1. Multi-tenant mode (LOCAL_MODE=false) is unchanged
|
||
|
|
2. Local mode (LOCAL_MODE=true) bootstrap works correctly
|
||
|
|
3. Meta endpoint is stable in all scenarios
|
||
|
|
"""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from unittest.mock import patch, AsyncMock
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from sqlalchemy import select
|
||
|
|
|
||
|
|
from app.config import Settings
|
||
|
|
from app.main import app
|
||
|
|
from app.models import Tenant
|
||
|
|
from app.services.local_bootstrap import LocalBootstrapService
|
||
|
|
|
||
|
|
|
||
|
|
class TestMultiTenantModeUnchanged:
|
||
|
|
"""
|
||
|
|
Tests that multi-tenant mode (LOCAL_MODE=false, the default) remains unchanged.
|
||
|
|
|
||
|
|
Key assertions:
|
||
|
|
- No automatic tenant creation on startup
|
||
|
|
- Tenants must be created manually via API
|
||
|
|
- Bootstrap service does nothing
|
||
|
|
"""
|
||
|
|
|
||
|
|
def test_local_mode_default_is_false(self):
|
||
|
|
"""Verify LOCAL_MODE defaults to False."""
|
||
|
|
settings = Settings()
|
||
|
|
assert settings.LOCAL_MODE is False
|
||
|
|
|
||
|
|
def test_bootstrap_service_skips_when_local_mode_false(self):
|
||
|
|
"""Verify bootstrap does nothing when LOCAL_MODE=false."""
|
||
|
|
# Reset class state
|
||
|
|
LocalBootstrapService._local_tenant_id = None
|
||
|
|
LocalBootstrapService._bootstrap_attempted = False
|
||
|
|
LocalBootstrapService._bootstrap_error = None
|
||
|
|
|
||
|
|
# Patch settings in the bootstrap module where it's used
|
||
|
|
with patch('app.services.local_bootstrap.settings') as mock_settings:
|
||
|
|
mock_settings.LOCAL_MODE = False
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
asyncio.get_event_loop().run_until_complete(LocalBootstrapService.run())
|
||
|
|
|
||
|
|
# Should not have attempted anything
|
||
|
|
assert LocalBootstrapService._bootstrap_attempted is False
|
||
|
|
assert LocalBootstrapService._local_tenant_id is None
|
||
|
|
|
||
|
|
def test_meta_endpoint_in_multi_tenant_mode(self):
|
||
|
|
"""Verify meta endpoint returns correct values in multi-tenant mode."""
|
||
|
|
# Reset bootstrap state
|
||
|
|
LocalBootstrapService._local_tenant_id = None
|
||
|
|
LocalBootstrapService._bootstrap_attempted = False
|
||
|
|
LocalBootstrapService._bootstrap_error = None
|
||
|
|
|
||
|
|
with patch('app.config.settings') as mock_settings:
|
||
|
|
mock_settings.LOCAL_MODE = False
|
||
|
|
mock_settings.INSTANCE_ID = None
|
||
|
|
mock_settings.APP_VERSION = "0.1.0"
|
||
|
|
|
||
|
|
client = TestClient(app)
|
||
|
|
response = client.get("/api/v1/meta/instance")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["local_mode"] is False
|
||
|
|
assert data["tenant_id"] is None
|
||
|
|
# instance_id may or may not be set depending on config
|
||
|
|
|
||
|
|
|
||
|
|
class TestLocalModeBootstrap:
|
||
|
|
"""
|
||
|
|
Tests for LOCAL_MODE=true bootstrap behavior.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def test_bootstrap_requires_instance_id(self):
|
||
|
|
"""Verify bootstrap fails gracefully without INSTANCE_ID."""
|
||
|
|
# Reset class state
|
||
|
|
LocalBootstrapService._local_tenant_id = None
|
||
|
|
LocalBootstrapService._bootstrap_attempted = False
|
||
|
|
LocalBootstrapService._bootstrap_error = None
|
||
|
|
|
||
|
|
with patch('app.services.local_bootstrap.settings') as mock_settings:
|
||
|
|
mock_settings.LOCAL_MODE = True
|
||
|
|
mock_settings.INSTANCE_ID = None
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
asyncio.get_event_loop().run_until_complete(LocalBootstrapService.run())
|
||
|
|
|
||
|
|
assert LocalBootstrapService._bootstrap_attempted is True
|
||
|
|
assert LocalBootstrapService._local_tenant_id is None
|
||
|
|
assert "INSTANCE_ID is required" in LocalBootstrapService._bootstrap_error
|
||
|
|
|
||
|
|
def test_bootstrap_status_tracking(self):
|
||
|
|
"""Verify bootstrap status is correctly tracked."""
|
||
|
|
# Reset class state
|
||
|
|
LocalBootstrapService._local_tenant_id = None
|
||
|
|
LocalBootstrapService._bootstrap_attempted = False
|
||
|
|
LocalBootstrapService._bootstrap_error = None
|
||
|
|
|
||
|
|
status = LocalBootstrapService.get_bootstrap_status()
|
||
|
|
assert status["attempted"] is False
|
||
|
|
assert status["success"] is False
|
||
|
|
assert status["tenant_id"] is None
|
||
|
|
assert status["error"] is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestMetaEndpointStability:
|
||
|
|
"""
|
||
|
|
Tests that /api/v1/meta/instance is stable in all scenarios.
|
||
|
|
|
||
|
|
Key assertions:
|
||
|
|
- Endpoint works before tenant exists
|
||
|
|
- Endpoint works after tenant bootstrap fails
|
||
|
|
- Endpoint always returns required fields
|
||
|
|
"""
|
||
|
|
|
||
|
|
def test_meta_endpoint_returns_required_fields(self):
|
||
|
|
"""Verify meta endpoint always returns required fields."""
|
||
|
|
# Reset bootstrap state
|
||
|
|
LocalBootstrapService._local_tenant_id = None
|
||
|
|
LocalBootstrapService._bootstrap_attempted = False
|
||
|
|
LocalBootstrapService._bootstrap_error = None
|
||
|
|
|
||
|
|
client = TestClient(app)
|
||
|
|
response = client.get("/api/v1/meta/instance")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
|
||
|
|
# These fields must always be present
|
||
|
|
assert "local_mode" in data
|
||
|
|
assert "version" in data
|
||
|
|
assert "bootstrap_status" in data
|
||
|
|
|
||
|
|
# These fields can be null but must be present
|
||
|
|
assert "instance_id" in data
|
||
|
|
assert "tenant_id" in data
|
||
|
|
|
||
|
|
def test_meta_endpoint_after_failed_bootstrap(self):
|
||
|
|
"""Verify meta endpoint works even after bootstrap failure."""
|
||
|
|
# Simulate failed bootstrap
|
||
|
|
LocalBootstrapService._local_tenant_id = None
|
||
|
|
LocalBootstrapService._bootstrap_attempted = True
|
||
|
|
LocalBootstrapService._bootstrap_error = "Test error"
|
||
|
|
|
||
|
|
client = TestClient(app)
|
||
|
|
response = client.get("/api/v1/meta/instance")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
|
||
|
|
# Should still return all fields
|
||
|
|
assert data["bootstrap_status"]["attempted"] is True
|
||
|
|
assert data["bootstrap_status"]["success"] is False
|
||
|
|
assert data["bootstrap_status"]["error"] == "Test error"
|
||
|
|
|
||
|
|
def test_meta_endpoint_with_successful_bootstrap(self):
|
||
|
|
"""Verify meta endpoint reflects successful bootstrap."""
|
||
|
|
tenant_id = uuid4()
|
||
|
|
|
||
|
|
# Simulate successful bootstrap
|
||
|
|
LocalBootstrapService._local_tenant_id = tenant_id
|
||
|
|
LocalBootstrapService._bootstrap_attempted = True
|
||
|
|
LocalBootstrapService._bootstrap_error = None
|
||
|
|
|
||
|
|
with patch('app.routes.meta.settings') as mock_settings:
|
||
|
|
mock_settings.LOCAL_MODE = True
|
||
|
|
mock_settings.INSTANCE_ID = "test-instance-123"
|
||
|
|
mock_settings.APP_VERSION = "0.1.0"
|
||
|
|
|
||
|
|
client = TestClient(app)
|
||
|
|
response = client.get("/api/v1/meta/instance")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
|
||
|
|
assert data["local_mode"] is True
|
||
|
|
assert data["instance_id"] == "test-instance-123"
|
||
|
|
assert data["tenant_id"] == str(tenant_id)
|
||
|
|
assert data["bootstrap_status"]["success"] is True
|
||
|
|
|
||
|
|
|
||
|
|
class TestConfigDefaults:
|
||
|
|
"""
|
||
|
|
Tests for configuration defaults related to LOCAL_MODE.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def test_all_local_mode_settings_have_safe_defaults(self):
|
||
|
|
"""Verify all LOCAL_MODE settings have safe defaults that don't break multi-tenant mode."""
|
||
|
|
settings = Settings()
|
||
|
|
|
||
|
|
# Core setting defaults to False (multi-tenant)
|
||
|
|
assert settings.LOCAL_MODE is False
|
||
|
|
|
||
|
|
# Optional settings default to None/False
|
||
|
|
assert settings.INSTANCE_ID is None
|
||
|
|
assert settings.HUB_URL is None
|
||
|
|
assert settings.HUB_API_KEY is None
|
||
|
|
assert settings.HUB_TELEMETRY_ENABLED is False
|
||
|
|
|
||
|
|
# Local tenant domain has a default
|
||
|
|
assert settings.LOCAL_TENANT_DOMAIN == "local.letsbe.cloud"
|
||
|
|
|
||
|
|
# Phase 2: LOCAL_AGENT_KEY defaults to None
|
||
|
|
assert settings.LOCAL_AGENT_KEY is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestLocalAgentRegistration:
|
||
|
|
"""
|
||
|
|
Tests for /api/v1/agents/register-local endpoint (Phase 2).
|
||
|
|
|
||
|
|
HTTP Status Semantics:
|
||
|
|
- 404: Endpoint hidden when LOCAL_MODE=false
|
||
|
|
- 401: Invalid or missing LOCAL_AGENT_KEY
|
||
|
|
- 503: Local tenant not bootstrapped
|
||
|
|
- 201: New agent created
|
||
|
|
- 200: Existing agent returned (idempotent)
|
||
|
|
"""
|
||
|
|
|
||
|
|
def test_register_local_hidden_when_local_mode_false(self):
|
||
|
|
"""Verify /register-local returns 404 when LOCAL_MODE=false (security by obscurity)."""
|
||
|
|
# Reset bootstrap state
|
||
|
|
LocalBootstrapService._local_tenant_id = None
|
||
|
|
LocalBootstrapService._bootstrap_attempted = False
|
||
|
|
LocalBootstrapService._bootstrap_error = None
|
||
|
|
|
||
|
|
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||
|
|
mock_settings = Settings()
|
||
|
|
mock_settings.LOCAL_MODE = False
|
||
|
|
mock_get_settings.return_value = mock_settings
|
||
|
|
|
||
|
|
client = TestClient(app)
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/agents/register-local",
|
||
|
|
json={"hostname": "test-agent", "version": "1.0.0"},
|
||
|
|
headers={"X-Local-Agent-Key": "any-key"},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
def test_register_local_requires_local_agent_key(self):
|
||
|
|
"""Verify /register-local returns 401 without X-Local-Agent-Key header."""
|
||
|
|
# Reset bootstrap state
|
||
|
|
LocalBootstrapService._local_tenant_id = uuid4()
|
||
|
|
LocalBootstrapService._bootstrap_attempted = True
|
||
|
|
LocalBootstrapService._bootstrap_error = None
|
||
|
|
|
||
|
|
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||
|
|
mock_settings = Settings()
|
||
|
|
mock_settings.LOCAL_MODE = True
|
||
|
|
mock_settings.LOCAL_AGENT_KEY = "test-key-12345"
|
||
|
|
mock_get_settings.return_value = mock_settings
|
||
|
|
|
||
|
|
client = TestClient(app)
|
||
|
|
# Missing header
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/agents/register-local",
|
||
|
|
json={"hostname": "test-agent", "version": "1.0.0"},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 422 # FastAPI validation error for missing header
|
||
|
|
|
||
|
|
def test_register_local_rejects_invalid_key(self):
|
||
|
|
"""Verify /register-local returns 401 with wrong LOCAL_AGENT_KEY."""
|
||
|
|
# Reset bootstrap state
|
||
|
|
LocalBootstrapService._local_tenant_id = uuid4()
|
||
|
|
LocalBootstrapService._bootstrap_attempted = True
|
||
|
|
LocalBootstrapService._bootstrap_error = None
|
||
|
|
|
||
|
|
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||
|
|
mock_settings = Settings()
|
||
|
|
mock_settings.LOCAL_MODE = True
|
||
|
|
mock_settings.LOCAL_AGENT_KEY = "correct-key"
|
||
|
|
mock_get_settings.return_value = mock_settings
|
||
|
|
|
||
|
|
client = TestClient(app)
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/agents/register-local",
|
||
|
|
json={"hostname": "test-agent", "version": "1.0.0"},
|
||
|
|
headers={"X-Local-Agent-Key": "wrong-key"},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 401
|
||
|
|
|
||
|
|
def test_register_local_503_when_not_bootstrapped(self):
|
||
|
|
"""Verify /register-local returns 503 if local tenant not bootstrapped."""
|
||
|
|
# Bootstrap not complete
|
||
|
|
LocalBootstrapService._local_tenant_id = None
|
||
|
|
LocalBootstrapService._bootstrap_attempted = False
|
||
|
|
LocalBootstrapService._bootstrap_error = None
|
||
|
|
|
||
|
|
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||
|
|
mock_settings = Settings()
|
||
|
|
mock_settings.LOCAL_MODE = True
|
||
|
|
mock_settings.LOCAL_AGENT_KEY = "test-key"
|
||
|
|
mock_get_settings.return_value = mock_settings
|
||
|
|
|
||
|
|
client = TestClient(app)
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/agents/register-local",
|
||
|
|
json={"hostname": "test-agent", "version": "1.0.0"},
|
||
|
|
headers={"X-Local-Agent-Key": "test-key"},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 503
|
||
|
|
assert "Retry-After" in response.headers
|