Merge pull request 'Implement Exoscale onboarding API endpoint' (#199) from 37-exoscale-onboarding into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 47s
Tests / test (push) Successful in 32s
Build and Deploy Staging / deploy (push) Successful in 34s

Reviewed-on: #199
This commit is contained in:
Tobias Kunze 2025-10-03 07:04:44 +00:00
commit cc23730d33
23 changed files with 926 additions and 55 deletions

View file

@ -40,6 +40,7 @@ SERVALA_EMAIL_PASSWORD=''
# At most one of the following settings may be set to True
SERVALA_EMAIL_TLS='False'
SERVALA_EMAIL_SSL='False'
SERVALA_EMAIL_DEFAULT_FROM='noreply@servala.com'
# If the default OrganizationOrigin is **not** the one with the database ID 1, set it here.
SERVALA_DEFAULT_ORIGIN='1'
@ -68,3 +69,7 @@ SERVALA_ODOO_USERNAME=''
SERVALA_ODOO_PASSWORD=''
# Helpdesk team ID for support tickets in Odoo. Defaults to 5.
SERVALA_ODOO_HELPDESK_TEAM_ID='5'
# OSB API authentication settings
SERVALA_OSB_USERNAME=''
SERVALA_OSB_PASSWORD=''

View file

@ -38,6 +38,12 @@ uv run --env-file=.env src/manage.py runserver
This will start the development server on http://localhost:8000.
For testing mail sending, `smtp4dev` can be used:
```
docker run --rm -it -p 5000:80 -p 2525:25 docker.io/rnwood/smtp4dev
```
## Configuration
Configuration happens using environment variables.

View file

@ -39,6 +39,7 @@ dev = [
"pytest>=8.4.2",
"pytest-cov>=6.3.0",
"pytest-django>=4.11.1",
"pytest-mock>=3.15.1",
]
[tool.isort]
@ -54,7 +55,7 @@ ignore = "E203,W503"
extend_exclude = "src/servala/static/mazer"
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "servala.settings"
DJANGO_SETTINGS_MODULE = "servala.settings_test"
addopts = "-p no:doctest -p no:pastebin -p no:nose --cov=./ --cov-report=term-missing:skip-covered"
testpaths = "src/tests"
pythonpath = "src"

View file

6
src/servala/api/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "servala.api"

View file

@ -0,0 +1,50 @@
import base64
from contextlib import suppress
from django.conf import settings
from django.http import HttpResponseForbidden
def get_username_and_password(request): # pragma: no cover
# This method is vendored from assorted DRF bits, so we
# skip it in our test coverage report
auth = request.META.get("HTTP_AUTHORIZATION", b"")
if isinstance(auth, str):
# Work around django test client oddness
auth = auth.encode("iso-8859-1")
auth = auth.split()
if not auth or auth[0].lower() != b"basic":
return False, False
if len(auth) != 2:
return False, False
with suppress(TypeError, ValueError):
try:
auth_decoded = base64.b64decode(auth[1]).decode("utf-8")
except UnicodeDecodeError:
auth_decoded = base64.b64decode(auth[1]).decode("latin-1")
return auth_decoded.split(":", 1)
return False, False
class OSBBasicAuthPermission:
"""
Basic auth for OSB is implemented as a permission class rather than as
an authentication class, because authentication is expected to associate
the request with a Django user. However, the OSB/Exoscale requests do not
relate to a user account, so we treat the auth result as a permission instead.
"""
def dispatch(self, request, *args, **kwargs):
osb_username = getattr(settings, "OSB_USERNAME", None)
osb_password = getattr(settings, "OSB_PASSWORD", None)
if not osb_username or not osb_password:
return False # pragma: no cover
username, password = get_username_and_password(request)
if username == osb_username and password == osb_password:
return super().dispatch(request, *args, **kwargs)
return HttpResponseForbidden()

13
src/servala/api/urls.py Normal file
View file

@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = "api"
urlpatterns = [
path(
"osb/v2/service_instances/<str:instance_id>",
views.OSBServiceInstanceView.as_view(),
name="osb_service_instance",
),
]

193
src/servala/api/views.py Normal file
View file

@ -0,0 +1,193 @@
import json
import logging
from contextlib import suppress
from django.conf import settings
from django.contrib.auth.decorators import login_not_required
from django.core.mail import send_mail
from django.db import transaction
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from servala.api.permissions import OSBBasicAuthPermission
from servala.core.exoscale import get_exoscale_origin
from servala.core.models import BillingEntity, Organization, User
from servala.core.models.service import Service, ServiceOffering
logger = logging.getLogger(__name__)
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class OSBServiceInstanceView(OSBBasicAuthPermission, View):
"""
OSB API endpoint for service instance provisioning (onboarding).
Implements the PUT /v2/service_instances/:instance_id endpoint.
https://docs.servala.com/exoscale-osb.html#_onboarding
"""
def _error(self, error):
return JsonResponse({"error": error}, status=400)
def _get_user(self, data):
email = data.get("email", "").strip().lower()
if not email:
raise ValueError("Email address is required but missing or empty")
full_name = data.get("full_name") or ""
name_parts = full_name.split(" ", 1)
first_name = name_parts[0] if name_parts else ""
last_name = name_parts[1] if len(name_parts) > 1 else ""
user, _ = User.objects.get_or_create(
email=email,
defaults={"first_name": first_name, "last_name": last_name},
)
return user
def put(self, request, instance_id):
"""
This implements the Exoscale onboarding flow.
https://docs.servala.com/exoscale-osb.html#_onboarding
https://community.exoscale.com/vendor/marketplace-managed-svc-prov/#provisioning
"""
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON in request body"}, status=400)
context = data.get("context", {})
parameters = data.get("parameters", {})
organization_guid = context.get("organization_guid") or data.get(
"organization_guid"
)
organization_name = context.get("organization_name")
organization_display_name = context.get(
"organization_display_name", organization_name
)
users = parameters.get("users", [])
service_id = data.get("service_id")
plan_id = data.get("plan_id")
if not organization_guid:
return self._error("organization_guid is required but missing.")
if not organization_name:
return self._error("organization_name is required but missing.")
if not users:
return self._error("users array is required but missing.")
if len(users) != 1:
return self._error("users array is expected to contain a single user.")
if not service_id:
return self._error("service_id is required but missing.")
if not plan_id:
return self._error("plan_id is required but missing.")
try:
user = self._get_user(users[0])
except Exception as e:
return self._error(f"Unable to create user: {e}")
try:
service = Service.objects.get(osb_service_id=service_id)
service_offering = ServiceOffering.objects.get(
osb_plan_id=plan_id, service=service
)
except Service.DoesNotExist:
return self._error(f"Unknown service_id: {service_id}")
except ServiceOffering.DoesNotExist:
return self._error(
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
)
exoscale_origin = get_exoscale_origin()
with suppress(Organization.DoesNotExist):
organization = Organization.objects.get(
osb_guid=organization_guid, origin=exoscale_origin
)
self._send_service_welcome_email(
request, organization, user, service, service_offering
)
return JsonResponse({"message": "Service already enabled"}, status=200)
odoo_data = {
"company_name": organization_display_name,
"invoice_email": user.email,
}
try:
with transaction.atomic():
billing_entity = BillingEntity.create_from_data(
name=f"{organization_display_name} (Exoscale)", odoo_data=odoo_data
)
organization = Organization(
name=organization_display_name,
billing_entity=billing_entity,
origin=exoscale_origin,
osb_guid=organization_guid,
)
organization = Organization.create_organization(organization, user)
self._send_invitation_email(request, organization, user)
self._send_service_welcome_email(
request, organization, user, service, service_offering
)
return JsonResponse(
{"message": "Successfully enabled service"}, status=201
)
except Exception as e:
logger.error(f"Error creating organization for Exoscale: {str(e)}")
return JsonResponse({"error": "Internal server error"}, status=500)
def _send_invitation_email(self, request, organization, user):
subject = f"Welcome to Servala - {organization.name}"
url = request.build_absolute_uri(organization.urls.base)
message = f"""Hello {user.first_name or user.email},
You have been invited to join the organization "{organization.name}" on Servala Portal.
You can access your organization at: {url}
Please use this email address ({user.email}) when prompted to log in.
Best regards,
The Servala Team"""
send_mail(
subject=subject,
message=message,
from_email=settings.EMAIL_DEFAULT_FROM,
recipient_list=[user.email],
fail_silently=False,
)
def _send_service_welcome_email(
self, request, organization, user, service, service_offering
):
service_path = f"{organization.urls.services}{service.slug}/offering/{service_offering.id}/"
service_url = request.build_absolute_uri(service_path)
subject = f"Get started with {service.name} - {organization.name}"
message = f"""Hello {user.first_name or user.email},
Your organization "{organization.name}" is now ready on Servala Portal!
You can create your {service.name} service directly here:
{service_url}
Or browse all available services at: {request.build_absolute_uri(organization.urls.services)}
Need help? Contact our support team through the portal.
Best regards,
The Servala Team"""
send_mail(
subject=subject,
message=message,
from_email=settings.EMAIL_DEFAULT_FROM,
recipient_list=[user.email],
fail_silently=False,
)

View file

@ -11,7 +11,6 @@ from servala.core.models import (
Organization,
OrganizationMembership,
OrganizationOrigin,
Plan,
Service,
ServiceCategory,
ServiceDefinition,
@ -99,11 +98,6 @@ class ServiceCategoryAdmin(admin.ModelAdmin):
autocomplete_fields = ("parent",)
class PlanInline(admin.TabularInline):
model = Plan
extra = 1
@admin.register(Service)
class ServiceAdmin(admin.ModelAdmin):
list_display = ("name", "category")
@ -230,14 +224,6 @@ class ControlPlaneAdmin(admin.ModelAdmin):
test_kubernetes_connection.short_description = _("Test Kubernetes connection")
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
list_display = ("name", "service_offering", "term")
list_filter = ("service_offering", "term")
search_fields = ("name", "description")
autocomplete_fields = ("service_offering",)
@admin.register(ServiceDefinition)
class ServiceDefinitionAdmin(admin.ModelAdmin):
form = ServiceDefinitionAdminForm
@ -317,7 +303,4 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
list_filter = ("service", "provider")
search_fields = ("description",)
autocomplete_fields = ("service", "provider")
inlines = (
ControlPlaneCRDInline,
PlanInline,
)
inlines = (ControlPlaneCRDInline,)

View file

@ -84,3 +84,5 @@ def check_servala_production_settings(app_configs, **kwargs):
id="servala.W001",
)
)
return errors

View file

@ -0,0 +1,11 @@
from servala.core.models import OrganizationOrigin
def get_exoscale_origin():
origin, _ = OrganizationOrigin.objects.get_or_create(
name="exoscale-marketplace",
defaults={
"description": "Organizations created via Exoscale marketplace onboarding"
},
)
return origin

View file

@ -35,7 +35,10 @@ class Migration(migrations.Migration):
name="external_links",
field=models.JSONField(
blank=True,
help_text='JSON array of link objects: {"url": "", "title": "", "featured": false}. Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages.',
help_text=(
'JSON array of link objects: {"url": "", "title": "", "featured": false}. '
"Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages."
),
null=True,
verbose_name="External links",
),

View file

@ -0,0 +1,49 @@
# Generated by Django 5.2.6 on 2025-10-02 07:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0007_controlplane_user_info_and_more"),
]
operations = [
migrations.AddField(
model_name="organization",
name="osb_guid",
field=models.CharField(
blank=True,
help_text="Open Service Broker GUID, used for organizations created via OSB API",
max_length=100,
null=True,
verbose_name="OSB GUID",
),
),
migrations.AddField(
model_name="service",
name="osb_service_id",
field=models.CharField(
blank=True,
help_text="Open Service Broker service ID for API matching",
max_length=100,
null=True,
verbose_name="OSB Service ID",
),
),
migrations.AddField(
model_name="serviceoffering",
name="osb_plan_id",
field=models.CharField(
blank=True,
help_text="Open Service Broker plan ID for API matching",
max_length=100,
null=True,
verbose_name="OSB Plan ID",
),
),
migrations.DeleteModel(
name="Plan",
),
]

View file

@ -9,7 +9,6 @@ from .service import (
CloudProvider,
ControlPlane,
ControlPlaneCRD,
Plan,
Service,
ServiceCategory,
ServiceDefinition,
@ -27,7 +26,6 @@ __all__ = [
"OrganizationMembership",
"OrganizationOrigin",
"OrganizationRole",
"Plan",
"Service",
"ServiceCategory",
"ServiceInstance",

View file

@ -53,6 +53,15 @@ class Organization(ServalaModelMixin, models.Model):
odoo_sale_order_name = models.CharField(
max_length=100, null=True, blank=True, verbose_name=_("Odoo Sale Order Name")
)
osb_guid = models.CharField(
max_length=100,
null=True,
blank=True,
verbose_name=_("OSB GUID"),
help_text=_(
"Open Service Broker GUID, used for organizations created via OSB API"
),
)
class urls(urlman.Urls):
base = "/org/{self.slug}/"

View file

@ -80,6 +80,13 @@ class Service(ServalaModelMixin, models.Model):
"Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages."
),
)
osb_service_id = models.CharField(
max_length=100,
null=True,
blank=True,
verbose_name=_("OSB Service ID"),
help_text=_("Open Service Broker service ID for API matching"),
)
class Meta:
verbose_name = _("Service")
@ -297,35 +304,6 @@ class CloudProvider(ServalaModelMixin, models.Model):
return self.name
class Plan(ServalaModelMixin, models.Model):
"""
Each service offering can have multiple plans, e.g. for different tiers.
"""
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, verbose_name=_("Description"))
# TODO schema
features = models.JSONField(verbose_name=_("Features"), null=True, blank=True)
# TODO schema
pricing = models.JSONField(verbose_name=_("Pricing"), null=True, blank=True)
term = models.PositiveIntegerField(
verbose_name=_("Term"), help_text=_("Term in months")
)
service_offering = models.ForeignKey(
to="ServiceOffering",
on_delete=models.PROTECT,
related_name="plans",
verbose_name=_("Service offering"),
)
class Meta:
verbose_name = _("Plan")
verbose_name_plural = _("Plans")
def __str__(self):
return self.name
def validate_api_definition(value):
required_fields = ("group", "version", "kind")
return validate_dict(value, required_fields)
@ -533,6 +511,13 @@ class ServiceOffering(ServalaModelMixin, models.Model):
verbose_name=_("Provider"),
)
description = models.TextField(blank=True, verbose_name=_("Description"))
osb_plan_id = models.CharField(
max_length=100,
null=True,
blank=True,
verbose_name=_("OSB Plan ID"),
help_text=_("Open Service Broker plan ID for API matching"),
)
class Meta:
verbose_name = _("Service offering")

View file

@ -21,10 +21,7 @@
<div class="col-12 col-md-6 col-lg-3">
<div class="card">
<div class="card-header card-header-with-logo">
{% if service.logo %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}">
{% endif %}
{% if service.logo %}<img src="{{ service.logo.url }}" alt="{{ service.name }}">{% endif %}
<div class="card-header-content">
<h4>{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>

View file

@ -72,9 +72,13 @@ EMAIL_HOST_USER = os.environ.get("SERVALA_EMAIL_USER", "")
EMAIL_HOST_PASSWORD = os.environ.get("SERVALA_EMAIL_PASSWORD", "")
EMAIL_USE_TLS = os.environ.get("SERVALA_EMAIL_TLS", "False") == "True"
EMAIL_USE_SSL = os.environ.get("SERVALA_EMAIL_SSL", "False") == "True"
EMAIL_DEFAULT_FROM = os.environ.get("SERVALA_EMAIL_DEFAULT_FROM", "noreply@servala.com")
SERVALA_DEFAULT_ORIGIN = int(os.environ.get("SERVALA_DEFAULT_ORIGIN", "1"))
OSB_USERNAME = os.environ.get("SERVALA_OSB_USERNAME")
OSB_PASSWORD = os.environ.get("SERVALA_OSB_PASSWORD")
SOCIALACCOUNT_PROVIDERS = {
"openid_connect": {
"APPS": [
@ -159,6 +163,7 @@ INSTALLED_APPS = [
"allauth.socialaccount.providers.openid_connect",
"auditlog",
"servala.core",
"servala.api",
]
MIDDLEWARE = [

View file

@ -0,0 +1,37 @@
"""
Django test settings that extend the main settings.
This file imports all settings from the main settings module and
overrides/adds settings specific to testing.
"""
from servala.settings import * # noqa: F403, F401
SECRET_KEY = "test-secret-key-for-testing-only-do-not-use-in-production"
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
"OPTIONS": {"timeout": 10},
}
}
OSB_USERNAME = "testuser"
OSB_PASSWORD = "testpass"
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"

View file

@ -20,6 +20,7 @@ urlpatterns = [
# - accounts/keycloak/login/callback/
path("accounts/", include("allauth.urls")),
path("admin/", admin.site.urls),
path("api/", include("servala.api.urls")),
]
# Serve static and media files in development

View file

@ -1,3 +1,5 @@
import base64
import pytest
from servala.core.models import (
@ -6,6 +8,12 @@ from servala.core.models import (
OrganizationOrigin,
User,
)
from servala.core.models.service import (
CloudProvider,
Service,
ServiceCategory,
ServiceOffering,
)
@pytest.fixture
@ -30,3 +38,76 @@ def org_owner(organization):
organization=organization, user=user, role="owner"
)
return user
@pytest.fixture
def test_service_category():
return ServiceCategory.objects.create(
name="Databases",
description="Database services",
)
@pytest.fixture
def test_service(test_service_category):
return Service.objects.create(
name="Redis",
slug="redis",
category=test_service_category,
description="Redis database service",
osb_service_id="test-service-123",
)
@pytest.fixture
def test_cloud_provider():
return CloudProvider.objects.create(
name="Exoscale",
description="Exoscale cloud provider",
)
@pytest.fixture
def test_service_offering(test_service, test_cloud_provider):
return ServiceOffering.objects.create(
service=test_service,
provider=test_cloud_provider,
description="Redis on Exoscale",
osb_plan_id="test-plan-123",
)
@pytest.fixture
def osb_client(client):
credentials = base64.b64encode(b"testuser:testpass").decode("ascii")
client.defaults = {"HTTP_AUTHORIZATION": f"Basic {credentials}"}
return client
@pytest.fixture
def mock_odoo_success(mocker):
"""
Mock Odoo client with successful responses for organization creation.
Returns the mock object for further customization if needed.
"""
mock_client = mocker.patch("servala.core.models.organization.CLIENT")
# Default successful responses for organization creation
mock_client.execute.side_effect = [
123, # company_id
456, # invoice_address_id
789, # sale_order_id
]
mock_client.search_read.return_value = [{"name": "SO001"}]
return mock_client
@pytest.fixture
def mock_odoo_failure(mocker):
"""
Mock Odoo client that raises an exception to simulate failure.
"""
mock_client = mocker.patch("servala.core.models.organization.CLIENT")
mock_client.execute.side_effect = Exception("Odoo connection failed")
return mock_client

View file

@ -0,0 +1,422 @@
import json
import pytest
from django.core import mail
from django_scopes import scopes_disabled
from servala.core.models import Organization, OrganizationOrigin, User
@pytest.fixture
def exoscale_origin():
origin, _ = OrganizationOrigin.objects.get_or_create(
name="exoscale-marketplace",
defaults={
"description": "Organizations created via Exoscale marketplace onboarding"
},
)
return origin
@pytest.fixture
def instance_id():
return "test-instance-123"
@pytest.fixture
def valid_osb_payload():
return {
"service_id": None,
"plan_id": None,
"context": {
"organization_guid": "test-org-guid-123",
"organization_name": "Test Organization",
"organization_display_name": "Test Organization Display",
},
"parameters": {
"users": [
{
"email": "test@example.com",
"full_name": "Test User",
"role": "owner",
}
]
},
}
@pytest.mark.django_db
def test_successful_onboarding_new_organization(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 201
response_data = json.loads(response.content)
assert response_data["message"] == "Successfully enabled service"
org = Organization.objects.get(osb_guid="test-org-guid-123")
assert org.name == "Test Organization Display"
assert org.origin == exoscale_origin
assert org.namespace.startswith("org-")
user = User.objects.get(email="test@example.com")
assert user.first_name == "Test"
assert user.last_name == "User"
with scopes_disabled():
membership = org.memberships.get(user=user)
assert membership.role == "owner"
billing_entity = org.billing_entity
assert billing_entity.name == "Test Organization Display (Exoscale)"
assert billing_entity.odoo_company_id == 123
assert billing_entity.odoo_invoice_id == 456
assert org.odoo_sale_order_id == 789
assert org.odoo_sale_order_name == "SO001"
assert len(mail.outbox) == 2
invitation_email = mail.outbox[0]
assert invitation_email.subject == "Welcome to Servala - Test Organization Display"
assert "test@example.com" in invitation_email.to
welcome_email = mail.outbox[1]
assert welcome_email.subject == "Get started with Redis - Test Organization Display"
assert "redis/offering/" in welcome_email.body
@pytest.mark.django_db
def test_duplicate_organization_returns_existing(
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
Organization.objects.create(
name="Existing Org",
osb_guid="test-org-guid-123",
origin=exoscale_origin,
)
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 200
response_data = json.loads(response.content)
assert response_data["message"] == "Service already enabled"
assert Organization.objects.filter(osb_guid="test-org-guid-123").count() == 1
assert len(mail.outbox) == 1 # Only one email was sent
@pytest.mark.django_db
def test_unauthenticated_osb_api_request_fails(
client,
test_service,
test_service_offering,
valid_osb_payload,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
response = client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 403
@pytest.mark.django_db
@pytest.mark.parametrize(
"field_to_remove,expected_error",
[
(
("context", "organization_guid"),
"organization_guid is required but missing",
),
(
("context", "organization_name"),
"organization_name is required but missing",
),
("service_id", "service_id is required but missing"),
("plan_id", "plan_id is required but missing"),
(("parameters", "users"), "users array is required but missing"),
],
)
def test_missing_required_fields_error(
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
field_to_remove,
expected_error,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
if isinstance(field_to_remove, tuple):
if field_to_remove[0] == "context":
del valid_osb_payload["context"][field_to_remove[1]]
elif field_to_remove[0] == "parameters":
del valid_osb_payload["parameters"][field_to_remove[1]]
else:
if field_to_remove in valid_osb_payload:
del valid_osb_payload[field_to_remove]
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert expected_error in response_data["error"]
@pytest.mark.django_db
def test_invalid_service_id_error(osb_client, valid_osb_payload, instance_id):
valid_osb_payload["service_id"] = 99999
valid_osb_payload["plan_id"] = 1
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Unknown service_id: 99999" in response_data["error"]
@pytest.mark.django_db
def test_invalid_plan_id_error(
osb_client, test_service, valid_osb_payload, instance_id
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = 99999
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert (
f"Unknown plan_id: 99999 for service_id: {test_service.osb_service_id}"
in response_data["error"]
)
@pytest.mark.django_db
def test_empty_users_array_error(
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"] = []
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "users array is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_multiple_users_error(
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"] = [
{"email": "user1@example.com", "full_name": "User One"},
{"email": "user2@example.com", "full_name": "User Two"},
]
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "users array is expected to contain a single user" in response_data["error"]
@pytest.mark.django_db
def test_empty_email_address_error(
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"] = [
{"email": "", "full_name": "User With No Email"},
]
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Unable to create user:" in response_data["error"]
@pytest.mark.django_db
def test_invalid_json_error(osb_client, instance_id):
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data="invalid json{",
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Invalid JSON in request body" in response_data["error"]
@pytest.mark.django_db
def test_user_creation_with_name_parsing(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"][0]["full_name"] = "John Doe Smith"
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 201
user = User.objects.get(email="test@example.com")
assert user.first_name == "John"
assert user.last_name == "Doe Smith"
@pytest.mark.django_db
def test_email_normalization(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"][0]["email"] = " TEST@EXAMPLE.COM "
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 201
user = User.objects.get(email="test@example.com")
assert user.email == "test@example.com"
@pytest.mark.django_db
def test_odoo_integration_failure_handling(
mock_odoo_failure,
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 500
response_data = json.loads(response.content)
assert response_data["error"] == "Internal server error"
@pytest.mark.django_db
def test_organization_creation_with_context_only(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
exoscale_origin,
instance_id,
):
payload = {
"service_id": test_service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id,
"context": {
"organization_guid": "fallback-org-guid",
"organization_name": "Fallback Organization",
},
"parameters": {
"users": [
{
"email": "fallback@example.com",
"full_name": "Fallback User",
}
]
},
}
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 201
org = Organization.objects.get(osb_guid="fallback-org-guid")
assert org is not None

14
uv.lock generated
View file

@ -963,6 +963,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@ -1234,6 +1246,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "pytest-mock" },
]
[package.metadata]
@ -1272,6 +1285,7 @@ dev = [
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-cov", specifier = ">=6.3.0" },
{ name = "pytest-django", specifier = ">=4.11.1" },
{ name = "pytest-mock", specifier = ">=3.15.1" },
]
[[package]]