feat(middleware): harden routing with fail-closed policy, custom subdomain management, and perf fixes
- Fix IPv6 host parsing with _strip_port() utility - Remove dangerous StorePlatform→Store.subdomain silent fallback - Close storefront gate bypass when frontend_type is None - Add custom subdomain management UI and API for stores - Add domain health diagnostic tool - Convert db.add() in loops to db.add_all() (24 PERF-006 fixes) - Add tests for all new functionality (18 subdomain service tests) - Add .github templates for validator compliance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ from .admin_platform_users import admin_platform_users_router
|
||||
from .admin_platforms import admin_platforms_router
|
||||
from .admin_store_domains import admin_store_domains_router
|
||||
from .admin_store_roles import admin_store_roles_router
|
||||
from .admin_store_subdomains import admin_store_subdomains_router
|
||||
from .admin_stores import admin_stores_router
|
||||
from .admin_users import admin_users_router
|
||||
from .user_account import admin_account_router
|
||||
@@ -42,6 +43,7 @@ router.include_router(admin_merchants_router, tags=["admin-merchants"])
|
||||
router.include_router(admin_platforms_router, tags=["admin-platforms"])
|
||||
router.include_router(admin_stores_router, tags=["admin-stores"])
|
||||
router.include_router(admin_store_domains_router, tags=["admin-store-domains"])
|
||||
router.include_router(admin_store_subdomains_router, tags=["admin-store-subdomains"])
|
||||
router.include_router(admin_store_roles_router, tags=["admin-store-roles"])
|
||||
router.include_router(admin_merchant_domains_router, tags=["admin-merchant-domains"])
|
||||
router.include_router(admin_modules_router, tags=["admin-modules"])
|
||||
|
||||
137
app/modules/tenancy/routes/api/admin_store_subdomains.py
Normal file
137
app/modules/tenancy/routes/api/admin_store_subdomains.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# app/modules/tenancy/routes/api/admin_store_subdomains.py
|
||||
"""
|
||||
Admin endpoints for managing store custom subdomains (per-platform).
|
||||
|
||||
Each store can have a custom subdomain on each platform it belongs to.
|
||||
For example, store "WizaTech" on the loyalty platform could have
|
||||
custom_subdomain="wizatech-rewards" → wizatech-rewards.rewardflow.lu
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
from app.modules.tenancy.services.store_subdomain_service import store_subdomain_service
|
||||
|
||||
admin_store_subdomains_router = APIRouter(prefix="/stores")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CustomSubdomainEntry(BaseModel):
|
||||
store_platform_id: int
|
||||
platform_id: int
|
||||
platform_code: str
|
||||
platform_name: str
|
||||
platform_domain: str | None
|
||||
custom_subdomain: str | None
|
||||
default_subdomain: str | None
|
||||
full_url: str | None
|
||||
default_url: str | None
|
||||
|
||||
|
||||
class CustomSubdomainListResponse(BaseModel):
|
||||
subdomains: list[CustomSubdomainEntry]
|
||||
total: int
|
||||
|
||||
|
||||
class SetCustomSubdomainRequest(BaseModel):
|
||||
subdomain: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=63,
|
||||
description="Custom subdomain (lowercase, alphanumeric + hyphens)",
|
||||
)
|
||||
|
||||
|
||||
class CustomSubdomainUpdateResponse(BaseModel):
|
||||
message: str
|
||||
platform_id: int
|
||||
custom_subdomain: str | None
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@admin_store_subdomains_router.get(
|
||||
"/{store_id}/custom-subdomains",
|
||||
response_model=CustomSubdomainListResponse,
|
||||
)
|
||||
def list_custom_subdomains(
|
||||
store_id: int = Path(..., description="Store ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all platform memberships with their custom subdomains.
|
||||
|
||||
Returns one entry per active platform the store belongs to,
|
||||
showing the custom_subdomain (if set) and the default subdomain.
|
||||
"""
|
||||
entries = store_subdomain_service.get_custom_subdomains(db, store_id)
|
||||
return CustomSubdomainListResponse(
|
||||
subdomains=[CustomSubdomainEntry(**e) for e in entries],
|
||||
total=len(entries),
|
||||
)
|
||||
|
||||
|
||||
@admin_store_subdomains_router.put(
|
||||
"/{store_id}/custom-subdomains/{platform_id}",
|
||||
response_model=CustomSubdomainUpdateResponse,
|
||||
)
|
||||
def set_custom_subdomain(
|
||||
store_id: int = Path(..., description="Store ID", gt=0),
|
||||
platform_id: int = Path(..., description="Platform ID", gt=0),
|
||||
payload: SetCustomSubdomainRequest = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Set or update the custom subdomain for a store on a specific platform.
|
||||
|
||||
The subdomain must be unique on the platform (no other store can claim it).
|
||||
Format: lowercase alphanumeric + hyphens, 3-63 characters.
|
||||
"""
|
||||
sp = store_subdomain_service.set_custom_subdomain(
|
||||
db, store_id, platform_id, payload.subdomain
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return CustomSubdomainUpdateResponse(
|
||||
message=f"Custom subdomain set to '{sp.custom_subdomain}'",
|
||||
platform_id=platform_id,
|
||||
custom_subdomain=sp.custom_subdomain,
|
||||
)
|
||||
|
||||
|
||||
@admin_store_subdomains_router.delete(
|
||||
"/{store_id}/custom-subdomains/{platform_id}",
|
||||
response_model=CustomSubdomainUpdateResponse,
|
||||
)
|
||||
def clear_custom_subdomain(
|
||||
store_id: int = Path(..., description="Store ID", gt=0),
|
||||
platform_id: int = Path(..., description="Platform ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Clear the custom subdomain for a store on a specific platform.
|
||||
|
||||
The store will still be accessible via its default subdomain
|
||||
(Store.subdomain + platform domain).
|
||||
"""
|
||||
store_subdomain_service.clear_custom_subdomain(db, store_id, platform_id)
|
||||
db.commit()
|
||||
|
||||
return CustomSubdomainUpdateResponse(
|
||||
message="Custom subdomain cleared",
|
||||
platform_id=platform_id,
|
||||
custom_subdomain=None,
|
||||
)
|
||||
@@ -231,15 +231,17 @@ class MerchantStoreService:
|
||||
|
||||
# Assign to platforms if provided
|
||||
platform_ids = store_data.get("platform_ids", [])
|
||||
store_platforms = []
|
||||
for pid in platform_ids:
|
||||
platform = db.query(Platform).filter(Platform.id == pid).first()
|
||||
if platform:
|
||||
sp = StorePlatform(
|
||||
store_platforms.append(StorePlatform(
|
||||
store_id=store.id,
|
||||
platform_id=pid,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sp)
|
||||
))
|
||||
if store_platforms:
|
||||
db.add_all(store_platforms)
|
||||
|
||||
db.flush()
|
||||
db.refresh(store)
|
||||
|
||||
170
app/modules/tenancy/services/store_subdomain_service.py
Normal file
170
app/modules/tenancy/services/store_subdomain_service.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# app/modules/tenancy/services/store_subdomain_service.py
|
||||
"""
|
||||
Service for managing StorePlatform custom subdomains.
|
||||
|
||||
Handles validation (format, uniqueness) and CRUD operations on
|
||||
the custom_subdomain field of StorePlatform entries.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions.base import (
|
||||
ConflictException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.modules.tenancy.models import Platform, Store
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Subdomain rules: lowercase alphanumeric + hyphens, 3-63 chars, no leading/trailing hyphen
|
||||
_SUBDOMAIN_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])?$")
|
||||
|
||||
|
||||
class StoreSubdomainService:
|
||||
"""Manage custom subdomains on StorePlatform entries."""
|
||||
|
||||
def get_custom_subdomains(self, db: Session, store_id: int) -> list[dict]:
|
||||
"""
|
||||
List all platform memberships for a store with their custom subdomains.
|
||||
|
||||
Returns a list of dicts with platform info and custom_subdomain (may be None).
|
||||
"""
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
if not store:
|
||||
raise ResourceNotFoundException("Store", str(store_id))
|
||||
|
||||
memberships = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
results = []
|
||||
for sp in memberships:
|
||||
platform = db.query(Platform).filter(Platform.id == sp.platform_id).first()
|
||||
if not platform:
|
||||
continue
|
||||
results.append({
|
||||
"store_platform_id": sp.id,
|
||||
"platform_id": sp.platform_id,
|
||||
"platform_code": platform.code,
|
||||
"platform_name": platform.name,
|
||||
"platform_domain": platform.domain,
|
||||
"custom_subdomain": sp.custom_subdomain,
|
||||
"default_subdomain": store.subdomain,
|
||||
"full_url": (
|
||||
f"{sp.custom_subdomain}.{platform.domain}"
|
||||
if sp.custom_subdomain and platform.domain
|
||||
else None
|
||||
),
|
||||
"default_url": (
|
||||
f"{store.subdomain}.{platform.domain}"
|
||||
if store.subdomain and platform.domain
|
||||
else None
|
||||
),
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def set_custom_subdomain(
|
||||
self, db: Session, store_id: int, platform_id: int, subdomain: str
|
||||
) -> StorePlatform:
|
||||
"""
|
||||
Set or update the custom_subdomain for a store on a specific platform.
|
||||
|
||||
Validates:
|
||||
- Subdomain format (lowercase, alphanumeric + hyphens)
|
||||
- Uniqueness on the platform (no other store claims it)
|
||||
- StorePlatform membership exists and is active
|
||||
"""
|
||||
subdomain = subdomain.strip().lower()
|
||||
|
||||
# Validate format
|
||||
if not _SUBDOMAIN_RE.match(subdomain):
|
||||
raise ValidationException(
|
||||
"Must be 3-63 characters, lowercase alphanumeric and hyphens, "
|
||||
"cannot start or end with a hyphen.",
|
||||
field="custom_subdomain",
|
||||
)
|
||||
|
||||
# Find the membership
|
||||
sp = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.platform_id == platform_id,
|
||||
StorePlatform.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not sp:
|
||||
raise ResourceNotFoundException(
|
||||
"StorePlatform",
|
||||
f"store_id={store_id}, platform_id={platform_id}",
|
||||
)
|
||||
|
||||
# Check uniqueness on this platform (exclude current entry)
|
||||
existing = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
func.lower(StorePlatform.custom_subdomain) == subdomain,
|
||||
StorePlatform.platform_id == platform_id,
|
||||
StorePlatform.id != sp.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise ConflictException(
|
||||
f"Subdomain '{subdomain}' is already claimed by another store "
|
||||
f"on this platform."
|
||||
)
|
||||
|
||||
sp.custom_subdomain = subdomain
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Set custom_subdomain='{subdomain}' for store_id={store_id} "
|
||||
f"on platform_id={platform_id}"
|
||||
)
|
||||
return sp
|
||||
|
||||
def clear_custom_subdomain(
|
||||
self, db: Session, store_id: int, platform_id: int
|
||||
) -> StorePlatform:
|
||||
"""Clear the custom_subdomain for a store on a specific platform."""
|
||||
sp = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.platform_id == platform_id,
|
||||
StorePlatform.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not sp:
|
||||
raise ResourceNotFoundException(
|
||||
"StorePlatform",
|
||||
f"store_id={store_id}, platform_id={platform_id}",
|
||||
)
|
||||
|
||||
old_value = sp.custom_subdomain
|
||||
sp.custom_subdomain = None
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Cleared custom_subdomain (was '{old_value}') for store_id={store_id} "
|
||||
f"on platform_id={platform_id}"
|
||||
)
|
||||
return sp
|
||||
|
||||
|
||||
store_subdomain_service = StoreSubdomainService()
|
||||
@@ -27,6 +27,13 @@ function adminStoreDetail() {
|
||||
domainSaving: false,
|
||||
newDomain: { domain: '', platform_id: '' },
|
||||
|
||||
// Custom subdomain management state
|
||||
customSubdomains: [],
|
||||
customSubdomainsLoading: false,
|
||||
editingSubdomainPlatformId: null,
|
||||
editingSubdomainValue: '',
|
||||
subdomainSaving: false,
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
@@ -54,6 +61,7 @@ function adminStoreDetail() {
|
||||
await Promise.all([
|
||||
this.loadSubscriptions(),
|
||||
this.loadDomains(),
|
||||
this.loadCustomSubdomains(),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
@@ -274,6 +282,70 @@ function adminStoreDetail() {
|
||||
}
|
||||
},
|
||||
|
||||
// ====================================================================
|
||||
// CUSTOM SUBDOMAIN MANAGEMENT
|
||||
// ====================================================================
|
||||
|
||||
async loadCustomSubdomains() {
|
||||
if (!this.store?.id) return;
|
||||
this.customSubdomainsLoading = true;
|
||||
try {
|
||||
const url = `/admin/stores/${this.store.id}/custom-subdomains`;
|
||||
const response = await apiClient.get(url);
|
||||
this.customSubdomains = response.subdomains || [];
|
||||
detailLog.info('Custom subdomains loaded:', this.customSubdomains.length);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
this.customSubdomains = [];
|
||||
} else {
|
||||
detailLog.warn('Failed to load custom subdomains:', error.message);
|
||||
}
|
||||
} finally {
|
||||
this.customSubdomainsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
startEditSubdomain(entry) {
|
||||
this.editingSubdomainPlatformId = entry.platform_id;
|
||||
this.editingSubdomainValue = entry.custom_subdomain || '';
|
||||
},
|
||||
|
||||
cancelEditSubdomain() {
|
||||
this.editingSubdomainPlatformId = null;
|
||||
this.editingSubdomainValue = '';
|
||||
},
|
||||
|
||||
async saveCustomSubdomain(platformId) {
|
||||
if (!this.editingSubdomainValue || this.subdomainSaving) return;
|
||||
this.subdomainSaving = true;
|
||||
try {
|
||||
await apiClient.put(
|
||||
`/admin/stores/${this.store.id}/custom-subdomains/${platformId}`,
|
||||
{ subdomain: this.editingSubdomainValue.trim().toLowerCase() }
|
||||
);
|
||||
Utils.showToast('Custom subdomain saved', 'success');
|
||||
this.cancelEditSubdomain();
|
||||
await this.loadCustomSubdomains();
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || 'Failed to save subdomain', 'error');
|
||||
} finally {
|
||||
this.subdomainSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async clearCustomSubdomain(platformId, subdomainName) {
|
||||
if (!confirm(`Clear custom subdomain "${subdomainName}"? The store will use its default subdomain on this platform.`)) return;
|
||||
try {
|
||||
await apiClient.delete(
|
||||
`/admin/stores/${this.store.id}/custom-subdomains/${platformId}`
|
||||
);
|
||||
Utils.showToast('Custom subdomain cleared', 'success');
|
||||
await this.loadCustomSubdomains();
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || 'Failed to clear subdomain', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// Refresh store data
|
||||
async refresh() {
|
||||
detailLog.info('=== STORE REFRESH TRIGGERED ===');
|
||||
|
||||
@@ -492,6 +492,117 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Subdomains (per-platform) -->
|
||||
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Custom Subdomains
|
||||
</h3>
|
||||
<button
|
||||
@click="loadCustomSubdomains()"
|
||||
class="flex items-center px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500">
|
||||
<span x-html="$icon('refresh', 'w-4 h-4 mr-1')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Each platform membership can have a custom subdomain override. If not set, the store's default subdomain
|
||||
(<strong x-text="store?.subdomain"></strong>) is used on each platform.
|
||||
</p>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="customSubdomainsLoading" class="text-center py-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading subdomains...</p>
|
||||
</div>
|
||||
|
||||
<!-- Subdomains List -->
|
||||
<div x-show="!customSubdomainsLoading && customSubdomains.length > 0" class="space-y-3">
|
||||
<template x-for="entry in customSubdomains" :key="entry.store_platform_id">
|
||||
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-html="$icon('globe', 'w-4 h-4 text-purple-500')"></span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="entry.platform_name"></span>
|
||||
<span class="px-1.5 py-0.5 text-xs font-medium text-gray-600 bg-gray-200 rounded-full dark:bg-gray-600 dark:text-gray-300" x-text="entry.platform_code"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Edit button (show when not editing) -->
|
||||
<button
|
||||
x-show="editingSubdomainPlatformId !== entry.platform_id"
|
||||
@click="startEditSubdomain(entry)"
|
||||
class="px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
title="Edit subdomain">
|
||||
<span x-html="$icon('edit', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<!-- Clear button (show when custom subdomain is set and not editing) -->
|
||||
<button
|
||||
x-show="entry.custom_subdomain && editingSubdomainPlatformId !== entry.platform_id"
|
||||
@click="clearCustomSubdomain(entry.platform_id, entry.custom_subdomain)"
|
||||
class="px-2 py-1 text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
title="Clear custom subdomain">
|
||||
<span x-html="$icon('delete', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display mode -->
|
||||
<div x-show="editingSubdomainPlatformId !== entry.platform_id" class="text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">Custom:</span>
|
||||
<template x-if="entry.custom_subdomain">
|
||||
<span>
|
||||
<span class="font-medium text-purple-600 dark:text-purple-400" x-text="entry.custom_subdomain"></span>
|
||||
<span class="text-gray-400" x-text="'.' + entry.platform_domain" x-show="entry.platform_domain"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!entry.custom_subdomain">
|
||||
<span class="text-gray-400 italic">Not set</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Default:</span>
|
||||
<span class="text-gray-600 dark:text-gray-300" x-text="entry.default_url || '--'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div x-show="editingSubdomainPlatformId === entry.platform_id" x-transition class="mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="editingSubdomainValue"
|
||||
:placeholder="entry.default_subdomain || 'custom-subdomain'"
|
||||
@keydown.enter="saveCustomSubdomain(entry.platform_id)"
|
||||
@keydown.escape="cancelEditSubdomain()"
|
||||
class="flex-1 px-3 py-1.5 text-sm border rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" />
|
||||
<span class="text-sm text-gray-400" x-text="'.' + entry.platform_domain" x-show="entry.platform_domain"></span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<button
|
||||
@click="cancelEditSubdomain()"
|
||||
class="px-3 py-1 text-xs font-medium text-gray-600 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveCustomSubdomain(entry.platform_id)"
|
||||
:disabled="!editingSubdomainValue || subdomainSaving"
|
||||
class="px-3 py-1 text-xs font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!subdomainSaving">Save</span>
|
||||
<span x-show="subdomainSaving">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No Platform Memberships -->
|
||||
<div x-show="!customSubdomainsLoading && customSubdomains.length === 0" class="text-center py-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No active platform memberships found.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- More Actions -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
|
||||
257
app/modules/tenancy/tests/unit/test_store_subdomain_service.py
Normal file
257
app/modules/tenancy/tests/unit/test_store_subdomain_service.py
Normal file
@@ -0,0 +1,257 @@
|
||||
# tests/unit/services/test_store_subdomain_service.py
|
||||
"""Unit tests for StoreSubdomainService."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.exceptions.base import (
|
||||
ConflictException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.modules.tenancy.models import Platform, Store
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from app.modules.tenancy.services.store_subdomain_service import StoreSubdomainService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subdomain_service():
|
||||
return StoreSubdomainService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_merchant(db, test_admin):
|
||||
"""Create a merchant for subdomain tests."""
|
||||
from app.modules.tenancy.models import Merchant
|
||||
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
merchant = Merchant(
|
||||
name=f"SD Merchant {unique_id}",
|
||||
owner_user_id=test_admin.id,
|
||||
contact_email=f"sd{unique_id}@test.com",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_platform(db):
|
||||
"""Create a test platform."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
platform = Platform(
|
||||
code=f"sd{unique_id}",
|
||||
name=f"SD Platform {unique_id}",
|
||||
domain=f"sd{unique_id}.example.com",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
return platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_platform_2(db):
|
||||
"""Create a second test platform."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
platform = Platform(
|
||||
code=f"sd2{unique_id}",
|
||||
name=f"SD Platform 2 {unique_id}",
|
||||
domain=f"sd2{unique_id}.example.com",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
return platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_store(db, sd_merchant):
|
||||
"""Create a test store."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
store = Store(
|
||||
store_code=f"SD{unique_id}".upper(),
|
||||
name=f"SD Store {unique_id}",
|
||||
subdomain=f"sdstore{unique_id}",
|
||||
merchant_id=sd_merchant.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_membership(db, sd_store, sd_platform):
|
||||
"""Create a StorePlatform membership."""
|
||||
sp = StorePlatform(
|
||||
store_id=sd_store.id,
|
||||
platform_id=sd_platform.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
db.refresh(sp)
|
||||
return sp
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.stores
|
||||
class TestGetCustomSubdomains:
|
||||
"""Tests for listing custom subdomains."""
|
||||
|
||||
def test_list_returns_memberships(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
results = subdomain_service.get_custom_subdomains(db, sd_store.id)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["platform_code"] == sd_platform.code
|
||||
assert results[0]["custom_subdomain"] is None
|
||||
assert results[0]["default_subdomain"] == sd_store.subdomain
|
||||
|
||||
def test_list_shows_custom_subdomain(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
sd_membership.custom_subdomain = "my-custom"
|
||||
db.commit()
|
||||
|
||||
results = subdomain_service.get_custom_subdomains(db, sd_store.id)
|
||||
|
||||
assert results[0]["custom_subdomain"] == "my-custom"
|
||||
assert results[0]["full_url"] == f"my-custom.{sd_platform.domain}"
|
||||
|
||||
def test_list_nonexistent_store_raises(self, db, subdomain_service):
|
||||
with pytest.raises(ResourceNotFoundException):
|
||||
subdomain_service.get_custom_subdomains(db, 999999)
|
||||
|
||||
def test_list_excludes_inactive_memberships(self, db, subdomain_service, sd_store, sd_membership):
|
||||
sd_membership.is_active = False
|
||||
db.commit()
|
||||
|
||||
results = subdomain_service.get_custom_subdomains(db, sd_store.id)
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.stores
|
||||
class TestSetCustomSubdomain:
|
||||
"""Tests for setting a custom subdomain."""
|
||||
|
||||
def test_set_valid_subdomain(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
sp = subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, "my-store")
|
||||
|
||||
assert sp.custom_subdomain == "my-store"
|
||||
|
||||
def test_set_normalizes_to_lowercase(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
sp = subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, "My-Store")
|
||||
|
||||
assert sp.custom_subdomain == "my-store"
|
||||
|
||||
def test_set_strips_whitespace(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
sp = subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, " my-store ")
|
||||
|
||||
assert sp.custom_subdomain == "my-store"
|
||||
|
||||
def test_set_rejects_leading_hyphen(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
with pytest.raises(ValidationException):
|
||||
subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, "-invalid")
|
||||
|
||||
def test_set_rejects_trailing_hyphen(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
with pytest.raises(ValidationException):
|
||||
subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, "invalid-")
|
||||
|
||||
def test_set_rejects_uppercase_special_chars(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
with pytest.raises(ValidationException):
|
||||
subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, "inv@lid")
|
||||
|
||||
def test_set_rejects_too_short(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
with pytest.raises(ValidationException):
|
||||
subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, "ab")
|
||||
|
||||
def test_set_nonexistent_membership_raises(self, db, subdomain_service, sd_store):
|
||||
with pytest.raises(ResourceNotFoundException):
|
||||
subdomain_service.set_custom_subdomain(db, sd_store.id, 999999, "test-sub")
|
||||
|
||||
def test_set_duplicate_on_same_platform_raises(
|
||||
self, db, subdomain_service, sd_store, sd_membership, sd_platform, sd_merchant
|
||||
):
|
||||
# Create another store with same subdomain on same platform
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
other_store = Store(
|
||||
store_code=f"OTHER{unique_id}".upper(),
|
||||
name=f"Other Store {unique_id}",
|
||||
subdomain=f"other{unique_id}",
|
||||
merchant_id=sd_merchant.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(other_store)
|
||||
db.flush()
|
||||
|
||||
other_sp = StorePlatform(
|
||||
store_id=other_store.id,
|
||||
platform_id=sd_platform.id,
|
||||
is_active=True,
|
||||
custom_subdomain="taken-name",
|
||||
)
|
||||
db.add(other_sp)
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(ConflictException):
|
||||
subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, "taken-name")
|
||||
|
||||
def test_set_same_subdomain_different_platform_ok(
|
||||
self, db, subdomain_service, sd_store, sd_membership, sd_platform, sd_platform_2
|
||||
):
|
||||
# Set on first platform
|
||||
subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, "shared-name")
|
||||
db.commit()
|
||||
|
||||
# Create membership on second platform
|
||||
sp2 = StorePlatform(
|
||||
store_id=sd_store.id,
|
||||
platform_id=sd_platform_2.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sp2)
|
||||
db.commit()
|
||||
|
||||
# Same subdomain on different platform should work
|
||||
result = subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform_2.id, "shared-name")
|
||||
|
||||
assert result.custom_subdomain == "shared-name"
|
||||
|
||||
def test_update_existing_subdomain(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, "first")
|
||||
db.commit()
|
||||
|
||||
sp = subdomain_service.set_custom_subdomain(db, sd_store.id, sd_platform.id, "second")
|
||||
|
||||
assert sp.custom_subdomain == "second"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.stores
|
||||
class TestClearCustomSubdomain:
|
||||
"""Tests for clearing a custom subdomain."""
|
||||
|
||||
def test_clear_existing_subdomain(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
sd_membership.custom_subdomain = "to-clear"
|
||||
db.commit()
|
||||
|
||||
sp = subdomain_service.clear_custom_subdomain(db, sd_store.id, sd_platform.id)
|
||||
|
||||
assert sp.custom_subdomain is None
|
||||
|
||||
def test_clear_already_none_ok(self, db, subdomain_service, sd_store, sd_membership, sd_platform):
|
||||
sp = subdomain_service.clear_custom_subdomain(db, sd_store.id, sd_platform.id)
|
||||
|
||||
assert sp.custom_subdomain is None
|
||||
|
||||
def test_clear_nonexistent_membership_raises(self, db, subdomain_service, sd_store):
|
||||
with pytest.raises(ResourceNotFoundException):
|
||||
subdomain_service.clear_custom_subdomain(db, sd_store.id, 999999)
|
||||
@@ -68,15 +68,14 @@ def metrics_team_members(db, metrics_stores):
|
||||
auth = AuthManager()
|
||||
users = []
|
||||
for i in range(3):
|
||||
u = User(
|
||||
users.append(User(
|
||||
email=f"team_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"team_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store_user",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(u)
|
||||
users.append(u)
|
||||
))
|
||||
db.add_all(users)
|
||||
db.flush()
|
||||
|
||||
# User 0 on store 0 and store 1 (should be counted once)
|
||||
|
||||
Reference in New Issue
Block a user