Replace plaintext token storage with SHA-256 hashes so leaked database contents cannot be used to authenticate. Plaintext is generated, shown once at creation time, and never persisted. - Add `hash_token()` helper and `MCPTokenManager.create_token()` that returns `(instance, plaintext)`. - Replace `token` field with indexed `token_hash`; look up bearers by hashing the incoming value. - Update dashboard, management command, and admin to surface plaintext only at creation. Disable admin "add" since it cannot reveal plaintext. - Migration drops the old `token` column and adds `token_hash`; pre-existing tokens are invalidated and must be reissued.
266 lines
9.0 KiB
Python
266 lines
9.0 KiB
Python
"""
|
|
Tests for Themis forms.
|
|
"""
|
|
from django.contrib.auth import get_user_model
|
|
from django.test import TestCase
|
|
|
|
from themis.encryption import encrypt_value
|
|
from themis.forms import APIKeyCreateForm, APIKeyEditForm, ProfileSettingsForm
|
|
from themis.models import UserAPIKey
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class ProfileSettingsFormTest(TestCase):
|
|
"""Tests for ProfileSettingsForm."""
|
|
|
|
def setUp(self):
|
|
self.user = User.objects.create_user(username="formuser", password="pass123")
|
|
self.profile = self.user.profile
|
|
|
|
def test_valid_data(self):
|
|
"""Form is valid with correct data."""
|
|
form = ProfileSettingsForm(
|
|
data={
|
|
"home_timezone": "America/Toronto",
|
|
"current_timezone": "",
|
|
"date_format": "DD/MM/YYYY",
|
|
"time_format": "12-hour",
|
|
"thousand_separator": "period",
|
|
"week_start": "sunday",
|
|
"theme_mode": "dark",
|
|
"theme_name": "dracula",
|
|
"dark_theme_name": "night",
|
|
"notifications_enabled": True,
|
|
"notifications_min_level": "info",
|
|
"browser_notifications_enabled": False,
|
|
"notification_retention_days": 30,
|
|
},
|
|
instance=self.profile,
|
|
)
|
|
self.assertTrue(form.is_valid())
|
|
|
|
def test_save_updates_profile(self):
|
|
"""Saving the form updates the profile instance."""
|
|
form = ProfileSettingsForm(
|
|
data={
|
|
"home_timezone": "Europe/London",
|
|
"current_timezone": "",
|
|
"date_format": "DD.MM.YYYY",
|
|
"time_format": "12-hour",
|
|
"thousand_separator": "space",
|
|
"week_start": "saturday",
|
|
"theme_mode": "light",
|
|
"theme_name": "cupcake",
|
|
"dark_theme_name": "halloween",
|
|
"notifications_enabled": True,
|
|
"notifications_min_level": "warning",
|
|
"browser_notifications_enabled": True,
|
|
"notification_retention_days": 60,
|
|
},
|
|
instance=self.profile,
|
|
)
|
|
self.assertTrue(form.is_valid())
|
|
form.save()
|
|
self.profile.refresh_from_db()
|
|
self.assertEqual(self.profile.home_timezone, "Europe/London")
|
|
self.assertEqual(self.profile.date_format, "DD.MM.YYYY")
|
|
self.assertEqual(self.profile.theme_name, "cupcake")
|
|
|
|
def test_invalid_timezone(self):
|
|
"""Form rejects invalid timezone values."""
|
|
form = ProfileSettingsForm(
|
|
data={
|
|
"home_timezone": "Invalid/Timezone",
|
|
"current_timezone": "",
|
|
"date_format": "YYYY-MM-DD",
|
|
"time_format": "24-hour",
|
|
"thousand_separator": "comma",
|
|
"week_start": "monday",
|
|
"theme_mode": "auto",
|
|
"theme_name": "corporate",
|
|
"dark_theme_name": "business",
|
|
},
|
|
instance=self.profile,
|
|
)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn("home_timezone", form.errors)
|
|
|
|
def test_correct_fields(self):
|
|
"""Form exposes the expected fields."""
|
|
form = ProfileSettingsForm(instance=self.profile)
|
|
expected_fields = {
|
|
"home_timezone",
|
|
"current_timezone",
|
|
"date_format",
|
|
"time_format",
|
|
"thousand_separator",
|
|
"week_start",
|
|
"theme_mode",
|
|
"theme_name",
|
|
"dark_theme_name",
|
|
"notifications_enabled",
|
|
"notifications_min_level",
|
|
"browser_notifications_enabled",
|
|
"notification_retention_days",
|
|
}
|
|
self.assertEqual(set(form.fields.keys()), expected_fields)
|
|
|
|
def test_widgets_have_daisyui_classes(self):
|
|
"""All widgets should have DaisyUI classes."""
|
|
form = ProfileSettingsForm(instance=self.profile)
|
|
for field_name, field in form.fields.items():
|
|
css_class = field.widget.attrs.get("class", "")
|
|
has_daisyui = any(
|
|
kw in css_class
|
|
for kw in ("select", "toggle", "input")
|
|
)
|
|
self.assertTrue(
|
|
has_daisyui,
|
|
f"{field_name} missing DaisyUI class (got: '{css_class}')",
|
|
)
|
|
|
|
|
|
class APIKeyCreateFormTest(TestCase):
|
|
"""Tests for APIKeyCreateForm."""
|
|
|
|
def test_valid_data(self):
|
|
"""Form is valid with required fields."""
|
|
form = APIKeyCreateForm(
|
|
data={
|
|
"service_name": "OpenAI",
|
|
"key_type": "api",
|
|
"label": "Work",
|
|
"key_value": "sk-abc123",
|
|
"instructions": "Get from dashboard",
|
|
"help_url": "https://platform.openai.com",
|
|
}
|
|
)
|
|
self.assertTrue(form.is_valid())
|
|
|
|
def test_valid_minimal_data(self):
|
|
"""Form is valid with only required fields."""
|
|
form = APIKeyCreateForm(
|
|
data={
|
|
"service_name": "Test",
|
|
"key_type": "api",
|
|
"key_value": "my-key",
|
|
}
|
|
)
|
|
self.assertTrue(form.is_valid())
|
|
|
|
def test_missing_service_name(self):
|
|
"""Form requires service_name."""
|
|
form = APIKeyCreateForm(
|
|
data={
|
|
"key_type": "api",
|
|
"key_value": "my-key",
|
|
}
|
|
)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn("service_name", form.errors)
|
|
|
|
def test_missing_key_value(self):
|
|
"""Form requires key_value."""
|
|
form = APIKeyCreateForm(
|
|
data={
|
|
"service_name": "Test",
|
|
"key_type": "api",
|
|
}
|
|
)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn("key_value", form.errors)
|
|
|
|
def test_invalid_key_type(self):
|
|
"""Form rejects invalid key_type."""
|
|
form = APIKeyCreateForm(
|
|
data={
|
|
"service_name": "Test",
|
|
"key_type": "invalid_type",
|
|
"key_value": "my-key",
|
|
}
|
|
)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn("key_type", form.errors)
|
|
|
|
def test_invalid_help_url(self):
|
|
"""Form rejects invalid URLs."""
|
|
form = APIKeyCreateForm(
|
|
data={
|
|
"service_name": "Test",
|
|
"key_type": "api",
|
|
"key_value": "my-key",
|
|
"help_url": "not-a-url",
|
|
}
|
|
)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn("help_url", form.errors)
|
|
|
|
def test_correct_fields(self):
|
|
"""Form has the expected fields."""
|
|
form = APIKeyCreateForm()
|
|
expected = {"service_name", "key_type", "label", "key_value", "instructions", "help_url"}
|
|
self.assertEqual(set(form.fields.keys()), expected)
|
|
|
|
|
|
class APIKeyEditFormTest(TestCase):
|
|
"""Tests for APIKeyEditForm."""
|
|
|
|
def setUp(self):
|
|
self.user = User.objects.create_user(username="edituser", password="pass123")
|
|
self.key = UserAPIKey.objects.create(
|
|
user=self.user,
|
|
service_name="OpenAI",
|
|
key_type="api",
|
|
encrypted_value=encrypt_value("sk-test"),
|
|
)
|
|
|
|
def test_valid_data(self):
|
|
"""Form is valid with correct data."""
|
|
form = APIKeyEditForm(
|
|
data={
|
|
"service_name": "Anthropic",
|
|
"key_type": "token",
|
|
"label": "New Label",
|
|
"instructions": "Updated instructions",
|
|
"help_url": "https://docs.anthropic.com",
|
|
"is_active": True,
|
|
},
|
|
instance=self.key,
|
|
)
|
|
self.assertTrue(form.is_valid())
|
|
|
|
def test_no_key_value_field(self):
|
|
"""Edit form does not include key_value or encrypted_value."""
|
|
form = APIKeyEditForm(instance=self.key)
|
|
self.assertNotIn("key_value", form.fields)
|
|
self.assertNotIn("encrypted_value", form.fields)
|
|
|
|
def test_correct_fields(self):
|
|
"""Form exposes metadata fields only."""
|
|
form = APIKeyEditForm(instance=self.key)
|
|
expected = {"service_name", "key_type", "label", "instructions", "help_url", "is_active"}
|
|
self.assertEqual(set(form.fields.keys()), expected)
|
|
|
|
def test_save_updates_metadata(self):
|
|
"""Saving updates metadata without touching encrypted_value."""
|
|
original_encrypted = self.key.encrypted_value
|
|
form = APIKeyEditForm(
|
|
data={
|
|
"service_name": "Updated Service",
|
|
"key_type": "token",
|
|
"label": "Updated",
|
|
"instructions": "",
|
|
"help_url": "",
|
|
"is_active": False,
|
|
},
|
|
instance=self.key,
|
|
)
|
|
self.assertTrue(form.is_valid())
|
|
form.save()
|
|
self.key.refresh_from_db()
|
|
self.assertEqual(self.key.service_name, "Updated Service")
|
|
self.assertEqual(self.key.key_type, "token")
|
|
self.assertFalse(self.key.is_active)
|
|
self.assertEqual(self.key.encrypted_value, original_encrypted)
|