Split OAuth credentials for member center and send engine
This commit is contained in:
parent
a6249e8854
commit
555462c71a
@ -0,0 +1,62 @@
|
|||||||
|
# Generated by Codex on 2026-07-07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("base", "0012_remove_newsletter_template_settings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="member_center_oauth_client_id",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Member Center OAuth client_id for usage=tenant_api.",
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="OAuth Client ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="member_center_oauth_client_secret",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Member Center OAuth client_secret for usage=tenant_api.",
|
||||||
|
verbose_name="OAuth Client Secret",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="member_center_oauth_token_path",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default="/oauth/token",
|
||||||
|
help_text="Shared Member Center OAuth token endpoint path for tenant_api and send_api clients.",
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="OAuth Token Path",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="send_engine_oauth_client_id",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Member Center OAuth client_id for usage=send_api. Uses the Member Center OAuth Token Path.",
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="Send Engine OAuth Client ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="send_engine_oauth_client_secret",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Member Center OAuth client_secret for usage=send_api. Uses the Member Center OAuth Token Path.",
|
||||||
|
verbose_name="Send Engine OAuth Client Secret",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -208,10 +208,20 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
max_length=255,
|
max_length=255,
|
||||||
blank=True,
|
blank=True,
|
||||||
default="/oauth/token",
|
default="/oauth/token",
|
||||||
|
help_text=_("Shared Member Center OAuth token endpoint path for tenant_api and send_api clients."),
|
||||||
verbose_name=_("OAuth Token Path"),
|
verbose_name=_("OAuth Token Path"),
|
||||||
)
|
)
|
||||||
member_center_oauth_client_id = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Client ID"))
|
member_center_oauth_client_id = models.CharField(
|
||||||
member_center_oauth_client_secret = models.TextField(blank=True, verbose_name=_("OAuth Client Secret"))
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Member Center OAuth client_id for usage=tenant_api."),
|
||||||
|
verbose_name=_("OAuth Client ID"),
|
||||||
|
)
|
||||||
|
member_center_oauth_client_secret = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Member Center OAuth client_secret for usage=tenant_api."),
|
||||||
|
verbose_name=_("OAuth Client Secret"),
|
||||||
|
)
|
||||||
member_center_oauth_scope = models.CharField(
|
member_center_oauth_scope = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
blank=True,
|
blank=True,
|
||||||
@ -236,6 +246,17 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
default="/api/send-jobs",
|
default="/api/send-jobs",
|
||||||
verbose_name=_("Send Jobs Path"),
|
verbose_name=_("Send Jobs Path"),
|
||||||
)
|
)
|
||||||
|
send_engine_oauth_client_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Member Center OAuth client_id for usage=send_api. Uses the Member Center OAuth Token Path."),
|
||||||
|
verbose_name=_("Send Engine OAuth Client ID"),
|
||||||
|
)
|
||||||
|
send_engine_oauth_client_secret = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Member Center OAuth client_secret for usage=send_api. Uses the Member Center OAuth Token Path."),
|
||||||
|
verbose_name=_("Send Engine OAuth Client Secret"),
|
||||||
|
)
|
||||||
send_engine_oauth_scope = models.CharField(max_length=255, blank=True, verbose_name=_("Send Engine OAuth Scope"))
|
send_engine_oauth_scope = models.CharField(max_length=255, blank=True, verbose_name=_("Send Engine OAuth Scope"))
|
||||||
send_engine_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Send Engine Timeout Seconds"))
|
send_engine_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Send Engine Timeout Seconds"))
|
||||||
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300, verbose_name=_("Retry Interval Seconds"))
|
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300, verbose_name=_("Retry Interval Seconds"))
|
||||||
@ -279,8 +300,6 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
"member_center_oauth_client_secret",
|
"member_center_oauth_client_secret",
|
||||||
widget=django_forms.PasswordInput(render_value=False),
|
widget=django_forms.PasswordInput(render_value=False),
|
||||||
),
|
),
|
||||||
FieldPanel("member_center_oauth_scope"),
|
|
||||||
FieldPanel("member_center_oauth_audience"),
|
|
||||||
FieldPanel("member_center_one_click_unsubscribe_path"),
|
FieldPanel("member_center_one_click_unsubscribe_path"),
|
||||||
FieldPanel("member_center_tenant_id"),
|
FieldPanel("member_center_tenant_id"),
|
||||||
FieldPanel("member_center_list_id"),
|
FieldPanel("member_center_list_id"),
|
||||||
@ -292,7 +311,11 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
[
|
[
|
||||||
FieldPanel("send_engine_base_url"),
|
FieldPanel("send_engine_base_url"),
|
||||||
FieldPanel("send_engine_send_jobs_path"),
|
FieldPanel("send_engine_send_jobs_path"),
|
||||||
FieldPanel("send_engine_oauth_scope"),
|
FieldPanel("send_engine_oauth_client_id"),
|
||||||
|
FieldPanel(
|
||||||
|
"send_engine_oauth_client_secret",
|
||||||
|
widget=django_forms.PasswordInput(render_value=False),
|
||||||
|
),
|
||||||
FieldPanel("send_engine_timeout_seconds"),
|
FieldPanel("send_engine_timeout_seconds"),
|
||||||
FieldPanel("send_engine_retry_interval_seconds"),
|
FieldPanel("send_engine_retry_interval_seconds"),
|
||||||
FieldPanel("send_engine_retry_max_attempts"),
|
FieldPanel("send_engine_retry_max_attempts"),
|
||||||
@ -323,12 +346,23 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
verbose_name = _("Newsletter System Settings")
|
verbose_name = _("Newsletter System Settings")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
previous = None
|
||||||
|
if self.pk:
|
||||||
|
previous = type(self).objects.filter(pk=self.pk).only(
|
||||||
|
"member_center_oauth_client_secret",
|
||||||
|
"send_engine_oauth_client_secret",
|
||||||
|
).first()
|
||||||
|
|
||||||
if self.pk and not self.member_center_oauth_client_secret:
|
if self.pk and not self.member_center_oauth_client_secret:
|
||||||
previous = type(self).objects.filter(pk=self.pk).only("member_center_oauth_client_secret").first()
|
|
||||||
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
|
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
|
||||||
elif self.member_center_oauth_client_secret:
|
elif self.member_center_oauth_client_secret:
|
||||||
self.member_center_oauth_client_secret = encrypt_text(self.member_center_oauth_client_secret)
|
self.member_center_oauth_client_secret = encrypt_text(self.member_center_oauth_client_secret)
|
||||||
|
|
||||||
|
if self.pk and not self.send_engine_oauth_client_secret:
|
||||||
|
self.send_engine_oauth_client_secret = previous.send_engine_oauth_client_secret if previous else ""
|
||||||
|
elif self.send_engine_oauth_client_secret:
|
||||||
|
self.send_engine_oauth_client_secret = encrypt_text(self.send_engine_oauth_client_secret)
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -41,25 +41,35 @@ class APIResult:
|
|||||||
error: str = ""
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_config_secret(value: str) -> str:
|
||||||
|
encrypted_secret = (value or "").strip()
|
||||||
|
if not encrypted_secret:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return decrypt_text(encrypted_secret)
|
||||||
|
except Exception:
|
||||||
|
return encrypted_secret
|
||||||
|
|
||||||
|
|
||||||
class MemberCenterClient:
|
class MemberCenterClient:
|
||||||
def __init__(self, config: NewsletterSystemSettings):
|
def __init__(self, config: NewsletterSystemSettings):
|
||||||
self.config = config
|
self.config = config
|
||||||
self._oauth_token_cache: dict[str, tuple[str, int]] = {}
|
self._oauth_token_cache: dict[str, tuple[str, int]] = {}
|
||||||
|
|
||||||
def subscribe(self, payload: dict) -> APIResult:
|
def subscribe(self, payload: dict) -> APIResult:
|
||||||
return self._post(self.config.member_center_subscribe_path, payload)
|
return self._post(self.config.member_center_subscribe_path, payload, use_oauth=True)
|
||||||
|
|
||||||
def confirm(self, token: str) -> APIResult:
|
def confirm(self, token: str) -> APIResult:
|
||||||
return self._get(self.config.member_center_confirm_path, {"token": token})
|
return self._get(self.config.member_center_confirm_path, {"token": token})
|
||||||
|
|
||||||
def request_unsubscribe_token(self, payload: dict) -> APIResult:
|
def request_unsubscribe_token(self, payload: dict) -> APIResult:
|
||||||
return self._post(self.config.member_center_unsubscribe_token_path, payload)
|
return self._post(self.config.member_center_unsubscribe_token_path, payload, use_oauth=True)
|
||||||
|
|
||||||
def unsubscribe(self, payload: dict) -> APIResult:
|
def unsubscribe(self, payload: dict) -> APIResult:
|
||||||
return self._post(self.config.member_center_unsubscribe_path, payload)
|
return self._post(self.config.member_center_unsubscribe_path, payload)
|
||||||
|
|
||||||
def one_click_unsubscribe(self, payload: dict) -> APIResult:
|
def one_click_unsubscribe(self, payload: dict) -> APIResult:
|
||||||
return self._post(self.config.member_center_one_click_unsubscribe_path, payload)
|
return self._post(self.config.member_center_one_click_unsubscribe_path, payload, use_oauth=True)
|
||||||
|
|
||||||
def list_subscriptions(self, list_id: str) -> APIResult:
|
def list_subscriptions(self, list_id: str) -> APIResult:
|
||||||
auth_headers, auth_error = self._auth_headers()
|
auth_headers, auth_error = self._auth_headers()
|
||||||
@ -71,50 +81,74 @@ class MemberCenterClient:
|
|||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get(self, path: str, query: dict, headers: dict | None = None) -> APIResult:
|
def _get(self, path: str, query: dict, headers: dict | None = None, use_oauth: bool = False) -> APIResult:
|
||||||
base_url = (self.config.member_center_base_url or "").strip()
|
base_url = (self.config.member_center_base_url or "").strip()
|
||||||
if not base_url:
|
if not base_url:
|
||||||
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
||||||
|
|
||||||
|
merged_headers = dict(headers or {})
|
||||||
|
if use_oauth:
|
||||||
|
auth_headers, auth_error = self._optional_auth_headers()
|
||||||
|
if auth_error:
|
||||||
|
return APIResult(ok=False, status=0, data={}, error=auth_error)
|
||||||
|
merged_headers.update(auth_headers)
|
||||||
|
|
||||||
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
||||||
if query:
|
if query:
|
||||||
endpoint = f"{endpoint}?{urlencode(query)}"
|
endpoint = f"{endpoint}?{urlencode(query)}"
|
||||||
|
|
||||||
request = Request(
|
request = Request(
|
||||||
endpoint,
|
endpoint,
|
||||||
headers={"Accept": "application/json", **(headers or {})},
|
headers={"Accept": "application/json", **merged_headers},
|
||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
||||||
return self._send(request, timeout=timeout)
|
return self._send(request, timeout=timeout)
|
||||||
|
|
||||||
def _post(self, path: str, payload: dict, headers: dict | None = None) -> APIResult:
|
def _post(self, path: str, payload: dict, headers: dict | None = None, use_oauth: bool = False) -> APIResult:
|
||||||
base_url = (self.config.member_center_base_url or "").strip()
|
base_url = (self.config.member_center_base_url or "").strip()
|
||||||
if not base_url:
|
if not base_url:
|
||||||
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
||||||
|
|
||||||
|
merged_headers = dict(headers or {})
|
||||||
|
if use_oauth:
|
||||||
|
auth_headers, auth_error = self._optional_auth_headers()
|
||||||
|
if auth_error:
|
||||||
|
return APIResult(ok=False, status=0, data={}, error=auth_error)
|
||||||
|
merged_headers.update(auth_headers)
|
||||||
|
|
||||||
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
request = Request(
|
request = Request(
|
||||||
endpoint,
|
endpoint,
|
||||||
data=body,
|
data=body,
|
||||||
headers={"Content-Type": "application/json", **(headers or {})},
|
headers={"Content-Type": "application/json", **merged_headers},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
||||||
return self._send(request, timeout=timeout)
|
return self._send(request, timeout=timeout)
|
||||||
|
|
||||||
|
def _oauth_credentials_configured(self) -> bool:
|
||||||
|
return bool(
|
||||||
|
(self.config.member_center_oauth_client_id or "").strip()
|
||||||
|
and (self.config.member_center_oauth_client_secret or "").strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _optional_auth_headers(self) -> tuple[dict, str]:
|
||||||
|
if not self._oauth_credentials_configured():
|
||||||
|
return {}, ""
|
||||||
|
return self._auth_headers()
|
||||||
|
|
||||||
def _auth_headers(self) -> tuple[dict, str]:
|
def _auth_headers(self) -> tuple[dict, str]:
|
||||||
scope = (self.config.member_center_oauth_scope or "").strip() or "newsletter:list.read"
|
token, error = self._get_oauth_access_token()
|
||||||
token, error = self._get_oauth_access_token(required_scope=scope)
|
|
||||||
if not token:
|
if not token:
|
||||||
return {}, error or "member center oauth token is empty"
|
return {}, error or "member center oauth token is empty"
|
||||||
return {"Authorization": f"Bearer {token}"}, ""
|
return {"Authorization": f"Bearer {token}"}, ""
|
||||||
|
|
||||||
def _get_oauth_access_token(self, required_scope: str) -> tuple[str, str]:
|
def _get_oauth_access_token(self) -> tuple[str, str]:
|
||||||
scope = (required_scope or "").strip()
|
cache_key = "member_center"
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
cached = self._oauth_token_cache.get(scope)
|
cached = self._oauth_token_cache.get(cache_key)
|
||||||
if cached and now < cached[1] - 30:
|
if cached and now < cached[1] - 30:
|
||||||
return cached[0], ""
|
return cached[0], ""
|
||||||
|
|
||||||
@ -125,27 +159,15 @@ class MemberCenterClient:
|
|||||||
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
||||||
token_url = urljoin(f"{base_url.rstrip('/')}/", token_path.lstrip("/"))
|
token_url = urljoin(f"{base_url.rstrip('/')}/", token_path.lstrip("/"))
|
||||||
client_id = (self.config.member_center_oauth_client_id or "").strip()
|
client_id = (self.config.member_center_oauth_client_id or "").strip()
|
||||||
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
|
client_secret = _decrypt_config_secret(self.config.member_center_oauth_client_secret)
|
||||||
client_secret = ""
|
|
||||||
if encrypted_secret:
|
|
||||||
try:
|
|
||||||
client_secret = decrypt_text(encrypted_secret)
|
|
||||||
except Exception:
|
|
||||||
client_secret = encrypted_secret
|
|
||||||
if not client_id or not client_secret:
|
if not client_id or not client_secret:
|
||||||
return "", "member center oauth client_id/client_secret is empty"
|
return "", "member center oauth client_id/client_secret is empty"
|
||||||
|
|
||||||
body = urlencode(
|
body = urlencode(
|
||||||
{
|
{
|
||||||
k: v
|
|
||||||
for k, v in {
|
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"client_secret": client_secret,
|
"client_secret": client_secret,
|
||||||
"scope": scope,
|
|
||||||
"audience": (self.config.member_center_oauth_audience or "").strip(),
|
|
||||||
}.items()
|
|
||||||
if v
|
|
||||||
}
|
}
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
request = Request(
|
request = Request(
|
||||||
@ -163,7 +185,7 @@ class MemberCenterClient:
|
|||||||
if not access_token:
|
if not access_token:
|
||||||
return "", "oauth token response missing access_token"
|
return "", "oauth token response missing access_token"
|
||||||
expires_in = int(result.data.get("expires_in") or 300)
|
expires_in = int(result.data.get("expires_in") or 300)
|
||||||
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in))
|
self._oauth_token_cache[cache_key] = (access_token, int(time.time()) + max(60, expires_in))
|
||||||
return access_token, ""
|
return access_token, ""
|
||||||
|
|
||||||
def _send(self, request: Request, timeout: int) -> APIResult:
|
def _send(self, request: Request, timeout: int) -> APIResult:
|
||||||
@ -473,13 +495,18 @@ class SendEngineClient:
|
|||||||
return {"Authorization": f"Bearer {token}"}, ""
|
return {"Authorization": f"Bearer {token}"}, ""
|
||||||
|
|
||||||
def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]:
|
def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]:
|
||||||
scope = (
|
client_id = (
|
||||||
(required_scope or "").strip()
|
(getattr(self.config, "send_engine_oauth_client_id", "") or "").strip()
|
||||||
or (self.config.send_engine_oauth_scope or "").strip()
|
or (self.config.member_center_oauth_client_id or "").strip()
|
||||||
or "newsletter:send.write"
|
|
||||||
)
|
)
|
||||||
|
encrypted_secret = (
|
||||||
|
(getattr(self.config, "send_engine_oauth_client_secret", "") or "").strip()
|
||||||
|
or (self.config.member_center_oauth_client_secret or "").strip()
|
||||||
|
)
|
||||||
|
client_secret = _decrypt_config_secret(encrypted_secret)
|
||||||
|
cache_key = client_id or "send_engine"
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
cached = self._oauth_token_cache.get(scope)
|
cached = self._oauth_token_cache.get(cache_key)
|
||||||
if cached and now < cached[1] - 30:
|
if cached and now < cached[1] - 30:
|
||||||
return cached[0], ""
|
return cached[0], ""
|
||||||
|
|
||||||
@ -489,27 +516,13 @@ class SendEngineClient:
|
|||||||
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
||||||
token_url = urljoin(f"{member_center_base_url.rstrip('/')}/", token_path.lstrip("/"))
|
token_url = urljoin(f"{member_center_base_url.rstrip('/')}/", token_path.lstrip("/"))
|
||||||
|
|
||||||
client_id = (self.config.member_center_oauth_client_id or "").strip()
|
|
||||||
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
|
|
||||||
client_secret = ""
|
|
||||||
if encrypted_secret:
|
|
||||||
try:
|
|
||||||
client_secret = decrypt_text(encrypted_secret)
|
|
||||||
except Exception:
|
|
||||||
client_secret = encrypted_secret
|
|
||||||
if not client_id or not client_secret:
|
if not client_id or not client_secret:
|
||||||
return ("", "member center oauth client_id/client_secret is empty")
|
return ("", "send engine oauth client_id/client_secret is empty")
|
||||||
body = urlencode(
|
body = urlencode(
|
||||||
{
|
{
|
||||||
k: v
|
|
||||||
for k, v in {
|
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"client_secret": client_secret,
|
"client_secret": client_secret,
|
||||||
"scope": scope,
|
|
||||||
"audience": (self.config.member_center_oauth_audience or "").strip(),
|
|
||||||
}.items()
|
|
||||||
if v
|
|
||||||
}
|
}
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
request = Request(
|
request = Request(
|
||||||
@ -528,7 +541,7 @@ class SendEngineClient:
|
|||||||
return "", "oauth token response missing access_token"
|
return "", "oauth token response missing access_token"
|
||||||
|
|
||||||
expires_in = int(result.data.get("expires_in") or 300)
|
expires_in = int(result.data.get("expires_in") or 300)
|
||||||
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in))
|
self._oauth_token_cache[cache_key] = (access_token, int(time.time()) + max(60, expires_in))
|
||||||
return access_token, ""
|
return access_token, ""
|
||||||
|
|
||||||
def _send(self, request: Request, timeout: int) -> APIResult:
|
def _send(self, request: Request, timeout: int) -> APIResult:
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import parse_qs
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
|
APIResult,
|
||||||
|
MemberCenterClient,
|
||||||
|
SendEngineClient,
|
||||||
extract_token,
|
extract_token,
|
||||||
generate_one_click_token,
|
generate_one_click_token,
|
||||||
build_weekly_news_body_html,
|
build_weekly_news_body_html,
|
||||||
@ -18,6 +22,107 @@ from .security import decrypt_text, encrypt_text
|
|||||||
|
|
||||||
|
|
||||||
class NewsletterTemplateTests(TestCase):
|
class NewsletterTemplateTests(TestCase):
|
||||||
|
def _member_center_config(self, **overrides):
|
||||||
|
defaults = {
|
||||||
|
"member_center_base_url": "https://member.example.com",
|
||||||
|
"member_center_subscribe_path": "/newsletter/subscribe",
|
||||||
|
"member_center_confirm_path": "/newsletter/confirm",
|
||||||
|
"member_center_unsubscribe_token_path": "/newsletter/unsubscribe-token",
|
||||||
|
"member_center_unsubscribe_path": "/newsletter/unsubscribe",
|
||||||
|
"member_center_one_click_unsubscribe_path": "/api/subscriptions/unsubscribe",
|
||||||
|
"member_center_oauth_client_id": "",
|
||||||
|
"member_center_oauth_client_secret": "",
|
||||||
|
"member_center_oauth_token_path": "/oauth/token",
|
||||||
|
"member_center_oauth_scope": "newsletter:list.read",
|
||||||
|
"member_center_oauth_audience": "",
|
||||||
|
"send_engine_oauth_client_id": "",
|
||||||
|
"send_engine_oauth_client_secret": "",
|
||||||
|
"send_engine_timeout_seconds": 10,
|
||||||
|
"member_center_timeout_seconds": 10,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return SimpleNamespace(**defaults)
|
||||||
|
|
||||||
|
def test_member_center_write_calls_include_bearer_when_oauth_is_configured(self):
|
||||||
|
client = MemberCenterClient(
|
||||||
|
self._member_center_config(
|
||||||
|
member_center_oauth_client_id="cms-client",
|
||||||
|
member_center_oauth_client_secret="cms-secret",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(client, "_get_oauth_access_token", return_value=("access-token", "")) as token_call,
|
||||||
|
patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={})) as send,
|
||||||
|
):
|
||||||
|
result = client.subscribe({"email": "demo@example.com"})
|
||||||
|
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
token_call.assert_called_once_with()
|
||||||
|
request = send.call_args.args[0]
|
||||||
|
self.assertEqual(request.get_header("Authorization"), "Bearer access-token")
|
||||||
|
|
||||||
|
def test_member_center_token_request_uses_client_credentials_without_scope_or_audience(self):
|
||||||
|
client = MemberCenterClient(
|
||||||
|
self._member_center_config(
|
||||||
|
member_center_oauth_client_id="tenant-client",
|
||||||
|
member_center_oauth_client_secret="tenant-secret",
|
||||||
|
member_center_oauth_scope="newsletter:subscriptions.write",
|
||||||
|
member_center_oauth_audience="member_center_api",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={"access_token": "token"})) as send:
|
||||||
|
token, error = client._get_oauth_access_token()
|
||||||
|
|
||||||
|
self.assertEqual(token, "token")
|
||||||
|
self.assertEqual(error, "")
|
||||||
|
request = send.call_args.args[0]
|
||||||
|
body = parse_qs(request.data.decode("utf-8"))
|
||||||
|
self.assertEqual(body["grant_type"], ["client_credentials"])
|
||||||
|
self.assertEqual(body["client_id"], ["tenant-client"])
|
||||||
|
self.assertEqual(body["client_secret"], ["tenant-secret"])
|
||||||
|
self.assertNotIn("scope", body)
|
||||||
|
self.assertNotIn("audience", body)
|
||||||
|
|
||||||
|
def test_send_engine_token_request_uses_send_engine_oauth_client_without_scope_or_audience(self):
|
||||||
|
client = SendEngineClient(
|
||||||
|
self._member_center_config(
|
||||||
|
member_center_oauth_client_id="tenant-client",
|
||||||
|
member_center_oauth_client_secret="tenant-secret",
|
||||||
|
send_engine_oauth_client_id="send-client",
|
||||||
|
send_engine_oauth_client_secret="send-secret",
|
||||||
|
member_center_oauth_audience="send_engine_api",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={"access_token": "token"})) as send:
|
||||||
|
token, error = client._get_member_center_access_token(required_scope="newsletter:send.write")
|
||||||
|
|
||||||
|
self.assertEqual(token, "token")
|
||||||
|
self.assertEqual(error, "")
|
||||||
|
request = send.call_args.args[0]
|
||||||
|
body = parse_qs(request.data.decode("utf-8"))
|
||||||
|
self.assertEqual(body["grant_type"], ["client_credentials"])
|
||||||
|
self.assertEqual(body["client_id"], ["send-client"])
|
||||||
|
self.assertEqual(body["client_secret"], ["send-secret"])
|
||||||
|
self.assertNotIn("scope", body)
|
||||||
|
self.assertNotIn("audience", body)
|
||||||
|
|
||||||
|
def test_member_center_write_calls_remain_unauthenticated_without_oauth_config(self):
|
||||||
|
client = MemberCenterClient(self._member_center_config())
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(client, "_get_oauth_access_token") as token_call,
|
||||||
|
patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={})) as send,
|
||||||
|
):
|
||||||
|
result = client.unsubscribe({"token": "unsubscribe-token"})
|
||||||
|
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
token_call.assert_not_called()
|
||||||
|
request = send.call_args.args[0]
|
||||||
|
self.assertIsNone(request.get_header("Authorization"))
|
||||||
|
|
||||||
def test_render_placeholders_replaces_known_keys(self):
|
def test_render_placeholders_replaces_known_keys(self):
|
||||||
template = "confirm={{confirm_url}} email={{email}} token={{token}}"
|
template = "confirm={{confirm_url}} email={{email}} token={{token}}"
|
||||||
rendered = render_placeholders(
|
rendered = render_placeholders(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user