diff --git a/app/models/settings.py b/app/models/settings.py index ca2ed424..a8403ee9 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -1,5 +1,6 @@ import re from enum import Enum, StrEnum +from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -197,6 +198,7 @@ class ConfigFormat(str, Enum): class SubRule(BaseModel): pattern: str target: ConfigFormat + response_headers: dict[str, Any] = Field(default_factory=dict) class SubFormatEnable(BaseModel): diff --git a/app/operation/subscription.py b/app/operation/subscription.py index 132a817b..2c7545ed 100644 --- a/app/operation/subscription.py +++ b/app/operation/subscription.py @@ -1,5 +1,7 @@ import re +from json import dumps as json_dumps from datetime import datetime as dt +from typing import Any from fastapi import Response from fastapi.responses import HTMLResponse @@ -30,6 +32,8 @@ class SubscriptionOperation(BaseOperation): + _ENCODED_RULE_RESPONSE_HEADERS = {"announce", "profile-title"} + @staticmethod async def validated_user(db_user: User) -> UsersResponseWithInbounds: user = UsersResponseWithInbounds.model_validate(db_user.__dict__) @@ -46,6 +50,14 @@ async def detect_client_type(user_agent: str, rules: list[SubRule]) -> ConfigFor if re.match(rule.pattern, user_agent): return rule.target + @staticmethod + def detect_client_rule(user_agent: str, rules: list[SubRule]) -> SubRule | None: + """Return the first matching subscription rule for the provided user agent.""" + for rule in rules: + if re.match(rule.pattern, user_agent): + return rule + return None + @staticmethod def _format_profile_title( user: UsersResponseWithInbounds, format_variables: dict, sub_settings: SubSettings @@ -67,7 +79,11 @@ def _format_profile_title( @staticmethod def create_response_headers( - user: UsersResponseWithInbounds, request_url: str, sub_settings: SubSettings, inline: bool = False + user: UsersResponseWithInbounds, + request_url: str, + sub_settings: SubSettings, + inline: bool = False, + extra_headers: dict[str, str] | None = None, ) -> dict: """Create response headers for subscription responses, including user subscription info.""" # Generate user subscription info @@ -89,7 +105,7 @@ def create_response_headers( # Use 'inline' for browser viewing, 'attachment' for download disposition = "inline" if inline else "attachment" - return { + headers = { "content-disposition": f'{disposition}; filename="{user.username}"', "profile-web-page-url": request_url, "support-url": support_url, @@ -99,6 +115,49 @@ def create_response_headers( "announce": encode_title(sub_settings.announce), "announce-url": sub_settings.announce_url, } + if extra_headers: + headers.update(extra_headers) + return headers + + @classmethod + def _format_rule_response_headers( + cls, rule: SubRule | None, format_variables: dict[str, str | int | float] + ) -> dict[str, str]: + if not rule or not rule.response_headers: + return {} + + headers: dict[str, str] = {} + for raw_name, raw_value in rule.response_headers.items(): + header_name = str(raw_name).strip() + if not header_name or raw_value is None: + continue + + formatted_value = cls._stringify_rule_header_value(raw_value, format_variables) + if not formatted_value: + continue + + if header_name.lower() in cls._ENCODED_RULE_RESPONSE_HEADERS: + formatted_value = encode_title(formatted_value) + + headers[header_name] = formatted_value + + return headers + + @staticmethod + def _stringify_rule_header_value(value: Any, format_variables: dict[str, str | int | float]) -> str: + if isinstance(value, str): + header_value = value.strip() + if not header_value: + return "" + try: + return header_value.format_map(format_variables) + except (ValueError, KeyError): + return header_value + + if isinstance(value, (dict, list, tuple, bool, int, float)): + return json_dumps(value, ensure_ascii=False, separators=(",", ":")) + + return str(value).strip() @staticmethod def create_info_response_headers(user: UsersResponseWithInbounds, sub_settings: SubSettings) -> dict: @@ -181,7 +240,8 @@ async def user_subscription( ) ) else: - client_type = await self.detect_client_type(user_agent, sub_settings.rules) + matched_rule = self.detect_client_rule(user_agent, sub_settings.rules) + client_type = matched_rule.target if matched_rule else None if client_type == ConfigFormat.block or not client_type: await self.raise_error(message="Client not supported", code=406) @@ -191,7 +251,13 @@ async def user_subscription( # If disable_sub_template is True and it's a browser request, use inline to view instead of download inline_view = sub_settings.disable_sub_template and is_browser_request - response_headers = self.create_response_headers(user, request_url, sub_settings, inline=inline_view) + response_headers = self.create_response_headers( + user, + request_url, + sub_settings, + inline=inline_view, + extra_headers=self._format_rule_response_headers(matched_rule, setup_format_variables(user)), + ) # Create response with appropriate headers return Response(content=conf, media_type=media_type, headers=response_headers) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 9495a582..73648aa6 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -229,6 +229,11 @@ "patternDescription": "Pattern to match against client requests", "target": "Target Format", "targetDescription": "Configuration format to serve for this pattern", + "responseHeaders": "Response Headers", + "responseHeadersDescription": "Optional headers to include when this rule matches.", + "addHeader": "Add Header", + "headerName": "Header name", + "headerValue": "Header value", "noRules": "No rules configured. Add rules to customize subscription behavior for different clients." }, "formats": { diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index cb7488cf..db928451 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -135,6 +135,11 @@ "patternDescription": "الگو برای تطبیق با درخواست‌های کلاینت", "target": "فرمت هدف", "targetDescription": "فرمت پیکربندی برای ارائه این الگو", + "responseHeaders": "هدرهای پاسخ", + "responseHeadersDescription": "هدرهای اختیاری که هنگام تطبیق این قانون به پاسخ اضافه می‌شوند.", + "addHeader": "افزودن هدر", + "headerName": "نام هدر", + "headerValue": "مقدار هدر", "noRules": "هیچ قانونی پیکربندی نشده است. قوانین را اضافه کنید تا رفتار اشتراک را برای کلاینت‌های مختلف سفارشی کنید." }, "formats": { diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index a158f58f..a8c87406 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -243,6 +243,11 @@ "patternDescription": "Шаблон для сопоставления с запросами клиентов", "target": "Целевой формат", "targetDescription": "Формат конфигурации для обслуживания этого шаблона", + "responseHeaders": "Заголовки ответа", + "responseHeadersDescription": "Дополнительные заголовки, которые добавляются при срабатывании этого правила.", + "addHeader": "Добавить заголовок", + "headerName": "Имя заголовка", + "headerValue": "Значение заголовка", "noRules": "Правила не настроены. Добавьте правила для настройки поведения подписки для разных клиентов." }, "formats": { diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index a17c4d41..0919915c 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -208,7 +208,12 @@ "patternPlaceholder": "输入客户端模式(例如:*android*)", "patternDescription": "匹配客户端用户代理的模式", "target": "目标格式", - "targetDescription": "匹配此模式时使用的配置格式" + "targetDescription": "匹配此模式时使用的配置格式", + "responseHeaders": "响应头", + "responseHeadersDescription": "当该规则匹配时附加到响应的可选头。", + "addHeader": "添加头", + "headerName": "头名称", + "headerValue": "头值" }, "formats": { "title": "手动订阅格式", diff --git a/dashboard/src/pages/_dashboard.settings.subscriptions.tsx b/dashboard/src/pages/_dashboard.settings.subscriptions.tsx index 197cd773..5ace357f 100644 --- a/dashboard/src/pages/_dashboard.settings.subscriptions.tsx +++ b/dashboard/src/pages/_dashboard.settings.subscriptions.tsx @@ -9,14 +9,14 @@ import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { VariablesPopover } from '@/components/ui/variables-popover' -import { ConfigFormat } from '@/service/api' +import { ConfigFormat, type SubRule as ApiSubRule } from '@/service/api' import { closestCenter, DndContext, DragEndEvent, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' import { rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { zodResolver } from '@hookform/resolvers/zod' import { Clock, Code, ExternalLink, FileCode2, FileText, Globe, GripVertical, HelpCircle, Link, Lock, Megaphone, Plus, RotateCcw, Settings, Shield, Shuffle, Sword, Trash2, User } from 'lucide-react' import { useEffect, useState } from 'react' -import { useFieldArray, useForm } from 'react-hook-form' +import { FieldErrors, useFieldArray, useForm, UseFormReturn } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { z } from 'zod' @@ -37,6 +37,7 @@ const subscriptionSchema = z.object({ z.object({ pattern: z.string().min(1, 'Pattern is required'), target: z.enum(['links', 'links_base64', 'xray', 'sing_box', 'clash', 'clash_meta', 'outline', 'block']), + response_headers: z.record(z.string()).optional(), }), ), applications: z @@ -86,6 +87,26 @@ const subscriptionSchema = z.object({ }) type SubscriptionFormData = z.infer +type SubscriptionRuleFormData = SubscriptionFormData['rules'][number] +type SubscriptionApplicationFormData = NonNullable[number] +type SubscriptionPlatform = SubscriptionApplicationFormData['platform'] +type SubscriptionLanguage = NonNullable[number]['language'] + +interface DefaultCatalogApp { + name: string + logo?: string + description?: string + faDescription?: string + ruDescription?: string + zhDescription?: string + configLink?: string + downloadLink: string +} + +interface DefaultOperatingSystem { + name: string + apps: DefaultCatalogApp[] +} const configFormatOptions = [ { value: 'links', label: 'settings.subscriptions.configFormats.links', icon: '🔗' }, @@ -99,7 +120,7 @@ const configFormatOptions = [ ] // Default Applications Dataset (mapped from provided data) -const defaultApplicationsData = { +const defaultApplicationsData: { operatingSystems: DefaultOperatingSystem[] } = { operatingSystems: [ { name: 'iOS', @@ -213,7 +234,7 @@ const defaultApplicationsData = { ], } -const mapOsNameToPlatform = (engName: string): 'android' | 'ios' | 'windows' | 'macos' | 'linux' | 'appletv' | 'androidtv' => { +const mapOsNameToPlatform = (engName: string): SubscriptionPlatform => { switch (engName.toLowerCase()) { case 'android': return 'android' @@ -253,13 +274,13 @@ const buildDefaultApplications = () => { if (finalRecommended) platformRecommendedChosen[platform] = true apps.push({ name: app.name, - icon_url: (app as any).logo || '', + icon_url: app.logo || '', import_url: app.configLink || '', description: { en: app.description || '', fa: app.faDescription || app.description || '', - ru: (app as any).ruDescription || app.description || '', - zh: (app as any).zhDescription || app.description || '', + ru: app.ruDescription || app.description || '', + zh: app.zhDescription || app.description || '', }, recommended: finalRecommended, platform, @@ -306,16 +327,54 @@ const defaultSubscriptionRules: { pattern: string; target: ConfigFormat }[] = [ // Sortable Rule Component interface SortableRuleProps { - rule: { pattern: string; target: ConfigFormat } + rule: SubscriptionRuleFormData index: number onRemove: (index: number) => void - form: any + form: UseFormReturn id: string } function SortableRule({ index, onRemove, form, id }: SortableRuleProps) { const { t } = useTranslation() const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }) + const responseHeaders = (form.watch(`rules.${index}.response_headers`) || {}) as Record + + const addResponseHeader = () => { + const nextKey = `x-header-${Object.keys(responseHeaders).length + 1}` + form.setValue( + `rules.${index}.response_headers`, + { + ...responseHeaders, + [nextKey]: '', + }, + { shouldDirty: true }, + ) + } + + const updateResponseHeaderName = (currentKey: string, nextKey: string) => { + const updatedHeaders = { ...responseHeaders } + const currentValue = updatedHeaders[currentKey] ?? '' + delete updatedHeaders[currentKey] + updatedHeaders[nextKey] = currentValue + form.setValue(`rules.${index}.response_headers`, updatedHeaders, { shouldDirty: true }) + } + + const updateResponseHeaderValue = (headerKey: string, value: string) => { + form.setValue( + `rules.${index}.response_headers`, + { + ...responseHeaders, + [headerKey]: value, + }, + { shouldDirty: true }, + ) + } + + const removeResponseHeader = (headerKey: string) => { + const updatedHeaders = { ...responseHeaders } + delete updatedHeaders[headerKey] + form.setValue(`rules.${index}.response_headers`, updatedHeaders, { shouldDirty: true }) + } const style = { transform: CSS.Transform.toString(transform), @@ -382,6 +441,43 @@ function SortableRule({ index, onRemove, form, id }: SortableRuleProps) { )} /> + +
+
+
+

{t('settings.subscriptions.rules.responseHeaders')}

+

{t('settings.subscriptions.rules.responseHeadersDescription')}

+
+ +
+ + {Object.entries(responseHeaders).length > 0 && ( +
+ {Object.entries(responseHeaders).map(([headerKey, headerValue]) => ( +
+ updateResponseHeaderName(headerKey, e.target.value)} + placeholder={t('settings.subscriptions.rules.headerName')} + className="h-7 border-muted bg-background/60 font-mono text-xs" + /> + updateResponseHeaderValue(headerKey, e.target.value)} + placeholder={t('settings.subscriptions.rules.headerValue')} + className="h-7 border-muted bg-background/60 font-mono text-xs" + /> + +
+ ))} +
+ )} +
{/* Delete button */} @@ -419,9 +515,14 @@ export default function SubscriptionSettings() { const [newAppRecommended, setNewAppRecommended] = useState(false) const [newLinkName, setNewLinkName] = useState('') const [newLinkUrl, setNewLinkUrl] = useState('') - const [newLinkLang, setNewLinkLang] = useState<'fa' | 'en' | 'ru' | 'zh'>('en') - const [newDescLang, setNewDescLang] = useState<'fa' | 'en' | 'ru' | 'zh'>('en') - const [newAppDescription, setNewAppDescription] = useState>({} as any) + const [newLinkLang, setNewLinkLang] = useState('en') + const [newDescLang, setNewDescLang] = useState('en') + const [newAppDescription, setNewAppDescription] = useState>({ + fa: '', + en: '', + ru: '', + zh: '', + }) const isValidIconUrl = (url: string): boolean => { if (!url || url.trim() === '') return false @@ -514,7 +615,7 @@ export default function SubscriptionSettings() { if (appOldIndex !== -1 && appNewIndex !== -1) { // Restrict sorting to within the same platform ("parent"). - const apps = form.getValues('applications') as any[] + const apps = (form.getValues('applications') || []) as SubscriptionApplicationFormData[] const oldPlatform = apps?.[appOldIndex]?.platform const newPlatform = apps?.[appNewIndex]?.platform if (oldPlatform && newPlatform && oldPlatform === newPlatform) { @@ -541,7 +642,14 @@ export default function SubscriptionSettings() { allow_browser_config: subscriptionData.allow_browser_config ?? true, disable_sub_template: subscriptionData.disable_sub_template ?? false, randomize_order: subscriptionData.randomize_order ?? false, - rules: subscriptionData.rules || [], + rules: + subscriptionData.rules?.map((rule: ApiSubRule) => ({ + pattern: rule.pattern, + target: rule.target, + response_headers: Object.fromEntries( + Object.entries(rule.response_headers || {}).map(([key, value]) => [key, typeof value === 'string' ? value : JSON.stringify(value)]), + ), + })) || [], applications: subscriptionData.applications || [], manual_sub_request: { links: subscriptionData.manual_sub_request?.links ?? true, @@ -558,6 +666,16 @@ export default function SubscriptionSettings() { const onSubmit = async (data: SubscriptionFormData) => { try { + const processedRules = (data.rules || []).map(rule => ({ + pattern: rule.pattern.trim(), + target: rule.target, + response_headers: Object.fromEntries( + Object.entries(rule.response_headers || {}) + .map(([key, value]) => [key.trim(), value.trim()] as const) + .filter(([key, value]) => key && value), + ), + })) + // Process applications data to ensure proper format // Normalize recommended: allow only one per platform const rawApps = (data.applications || []) @@ -590,7 +708,7 @@ export default function SubscriptionSettings() { }) // Filter out empty values and prepare the payload - const filteredData: any = { + const filteredData = { subscription: { ...data, // Convert empty strings to undefined @@ -599,18 +717,19 @@ export default function SubscriptionSettings() { profile_title: data.profile_title?.trim() || undefined, announce: data.announce?.trim() || undefined, announce_url: data.announce_url?.trim() || undefined, + rules: processedRules, // Include processed applications applications: processedApplications, }, } await updateSettings(filteredData) - } catch (error) { + } catch { // Error handling is done in the parent context } } - const onInvalid = (errors: any) => { + const onInvalid = (errors: FieldErrors) => { // Specific: if any application name is missing, show translated required message const appsErrors = errors?.applications if (Array.isArray(appsErrors)) { @@ -643,17 +762,18 @@ export default function SubscriptionSettings() { } // Try to extract the first human-friendly message from nested errors - const extractFirstMessage = (errObj: any): string | undefined => { + const extractFirstMessage = (errObj: unknown): string | undefined => { if (!errObj) return undefined if (Array.isArray(errObj)) { for (const item of errObj) { const msg = extractFirstMessage(item) if (msg) return msg } - } else if (typeof errObj === 'object') { - if (errObj.message && typeof errObj.message === 'string') return errObj.message - for (const key of Object.keys(errObj)) { - const msg = extractFirstMessage(errObj[key]) + } else if (typeof errObj === 'object' && errObj !== null) { + const errorRecord = errObj as Record + if (typeof errorRecord.message === 'string') return errorRecord.message + for (const key of Object.keys(errorRecord)) { + const msg = extractFirstMessage(errorRecord[key]) if (msg) return msg } } @@ -677,7 +797,14 @@ export default function SubscriptionSettings() { allow_browser_config: subscriptionData.allow_browser_config ?? true, disable_sub_template: subscriptionData.disable_sub_template ?? false, randomize_order: subscriptionData.randomize_order ?? false, - rules: subscriptionData.rules || [], + rules: + subscriptionData.rules?.map((rule: ApiSubRule) => ({ + pattern: rule.pattern, + target: rule.target, + response_headers: Object.fromEntries( + Object.entries(rule.response_headers || {}).map(([key, value]) => [key, typeof value === 'string' ? value : JSON.stringify(value)]), + ), + })) || [], applications: subscriptionData.applications || [], manual_sub_request: { links: subscriptionData.manual_sub_request?.links ?? true, @@ -709,7 +836,7 @@ export default function SubscriptionSettings() { } const addRule = () => { - appendRule({ pattern: '', target: 'links' as ConfigFormat }) + appendRule({ pattern: '', target: 'links' as ConfigFormat, response_headers: {} }) } const addApplication = () => { @@ -1140,7 +1267,8 @@ export default function SubscriptionSettings() { {/* Group items by platform and render separate SortableContexts to isolate drag-and-drop containers */} {(['ios', 'android', 'windows', 'macos', 'linux', 'appletv', 'androidtv'] as const).map(platformKey => { - const indices = applicationFields.map((f, idx) => ({ id: f.id, idx })).filter(({ idx }) => (form.getValues('applications') as any[])?.[idx]?.platform === platformKey) + const currentApplications = (form.getValues('applications') || []) as SubscriptionApplicationFormData[] + const indices = applicationFields.map((f, idx) => ({ id: f.id, idx })).filter(({ idx }) => currentApplications[idx]?.platform === platformKey) if (indices.length === 0) return null return ( i.id)} strategy={rectSortingStrategy}> @@ -1336,7 +1464,7 @@ export default function SubscriptionSettings() {
{t('settings.subscriptions.applications.platform')} - setNewAppPlatform(v)}> @@ -1393,7 +1521,7 @@ export default function SubscriptionSettings() {
{t('settings.subscriptions.applications.descriptionApp')}
- setNewDescLang(v)}> @@ -1444,7 +1572,7 @@ export default function SubscriptionSettings() { className="h-8 flex-1 min-w-0 font-mono text-xs" dir="ltr" /> - setNewLinkLang(v)}> diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts index de0900ac..77cc4797 100644 --- a/dashboard/src/service/api/index.ts +++ b/dashboard/src/service/api/index.ts @@ -1125,6 +1125,9 @@ export interface SubscriptionUserResponse { export interface SubRule { pattern: string target: ConfigFormat + response_headers?: { + [key: string]: unknown + } } export interface SubFormatEnable { diff --git a/tests/api/test_user.py b/tests/api/test_user.py index d68ca55f..9ea323c9 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -3,7 +3,10 @@ from fastapi import status from tests.api import client +from app.operation.subscription import SubscriptionOperation +from app.models.settings import ConfigFormat, SubRule from tests.api.helpers import ( + auth_headers, create_core, create_group, create_user, @@ -223,6 +226,93 @@ def test_user_sub_update_user_agent_truncates_long_values(access_token): cleanup_groups(access_token, core, groups) +def test_user_subscription_applies_rule_response_headers(access_token): + """Custom rule response headers should persist and keep subscription requests healthy.""" + settings_response = client.get("/api/settings", headers=auth_headers(access_token)) + assert settings_response.status_code == status.HTTP_200_OK + original_subscription = settings_response.json()["subscription"] + + updated_subscription = { + **original_subscription, + "rules": [ + { + "pattern": r"^PasarGuardRuleHeaderClient$", + "target": "links", + "response_headers": { + "x-subheader": "Hello {USERNAME}", + "profile-title": "Rule Profile {USERNAME}", + }, + }, + *original_subscription["rules"], + ], + } + + update_response = client.put( + "/api/settings", + headers=auth_headers(access_token), + json={"subscription": updated_subscription}, + ) + assert update_response.status_code == status.HTTP_200_OK + assert update_response.json()["subscription"]["rules"][0]["response_headers"]["x-subheader"] == "Hello {USERNAME}" + + core, groups = setup_groups(access_token, 1) + hosts = create_hosts_for_inbounds(access_token) + user = create_user( + access_token, + group_ids=[groups[0]["id"]], + payload={"username": unique_name("test_user_rule_response_headers")}, + ) + + try: + response = client.get( + user["subscription_url"], + headers={"User-Agent": "PasarGuardRuleHeaderClient"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.text + finally: + restore_response = client.put( + "/api/settings", + headers=auth_headers(access_token), + json={"subscription": original_subscription}, + ) + assert restore_response.status_code == status.HTTP_200_OK + delete_user(access_token, user["username"]) + for host in hosts: + client.delete(f"/api/host/{host['id']}", headers=auth_headers(access_token)) + cleanup_groups(access_token, core, groups) + + +def test_format_rule_response_headers_supports_strings_and_json(): + rule = SubRule( + pattern=r"^TestClient$", + target=ConfigFormat.links, + response_headers={ + "x-subheader": "Hello {USERNAME}", + "x-json": {"enabled": True, "count": 2}, + }, + ) + + headers = SubscriptionOperation._format_rule_response_headers(rule, {"USERNAME": "alice"}) + + assert headers["x-subheader"] == "Hello alice" + assert headers["x-json"] == '{"enabled":true,"count":2}' + + +def test_detect_client_rule_matches_user_agent(): + rule = SubRule( + pattern=r"^PasarGuardRuleHeaderClient$", + target=ConfigFormat.links, + response_headers={"x-subheader": "Hello {USERNAME}"}, + ) + + matched_rule = SubscriptionOperation.detect_client_rule("PasarGuardRuleHeaderClient", [rule]) + + assert matched_rule is not None + assert matched_rule.target == ConfigFormat.links + assert matched_rule.response_headers["x-subheader"] == "Hello {USERNAME}" + + def test_user_get(access_token): """Test that the user get by id route is accessible.""" core, groups = setup_groups(access_token, 1)