sub2api/backend/internal/service/notification_email_service_test.go
benjamin 7e0b22ceb6 feat(email): 扩展邮件模板错误处理
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-20 13:25:03 +08:00

344 lines
13 KiB
Go

package service
import (
"context"
"errors"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
func TestNotificationEmailPreviewEscapesHTMLAndSanitizesSubject(t *testing.T) {
ctx := context.Background()
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{
Event: NotificationEmailEventBalanceLow,
Locale: "en-US,en;q=0.9",
Subject: "Low balance for {{recipient_name}}\r\nInjected",
HTML: `<p>{{recipient_name}}</p><a href="{{recharge_url}}">Recharge</a>`,
Variables: map[string]string{
"recipient_name": `<script>alert("x")</script>`,
"recharge_url": `javascript:alert(1)`,
},
})
require.NoError(t, err)
require.NotContains(t, preview.Subject, "\r")
require.NotContains(t, preview.Subject, "\n")
require.Contains(t, preview.Subject, `Low balance for <script>alert("x")</script>Injected`)
require.Contains(t, preview.HTML, `&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;`)
require.NotContains(t, preview.HTML, `javascript:alert(1)`)
require.Contains(t, preview.HTML, `href=""`)
}
func TestNotificationEmailTemplateOverrideAndRestore(t *testing.T) {
ctx := context.Background()
repo := newNotificationEmailMemorySettingRepo()
svc := NewNotificationEmailService(repo, nil)
official, err := svc.GetTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "en")
require.NoError(t, err)
require.False(t, official.IsCustom)
updated, err := svc.UpdateTemplate(
ctx,
NotificationEmailEventBalanceRechargeSuccess,
"zh-Hans",
"充值完成:{{recharge_amount}}",
"<p>{{recipient_name}} 已充值 {{recharge_amount}}</p>",
)
require.NoError(t, err)
require.True(t, updated.IsCustom)
require.Equal(t, "zh", updated.Locale)
require.Equal(t, "充值完成:{{recharge_amount}}", updated.Subject)
require.NotNil(t, updated.UpdatedAt)
restored, err := svc.RestoreOfficialTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "zh")
require.NoError(t, err)
require.False(t, restored.IsCustom)
require.NotEqual(t, updated.Subject, restored.Subject)
_, err = repo.GetValue(ctx, notificationEmailTemplateKey(NotificationEmailEventBalanceRechargeSuccess, "zh"))
require.ErrorIs(t, err, ErrSettingNotFound)
}
func TestNotificationEmailTemplateRejectsUnsupportedPlaceholder(t *testing.T) {
ctx := context.Background()
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
_, err := svc.UpdateTemplate(
ctx,
NotificationEmailEventSubscriptionPurchaseSuccess,
"en",
"Purchased {{not_allowed}}",
"<p>{{subscription_group}}</p>",
)
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported placeholder")
}
func TestNotificationEmailAuthTemplatesAreListedAndPreviewable(t *testing.T) {
ctx := context.Background()
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
infos := svc.ListEventInfos()
events := make(map[string]NotificationEmailEventInfo, len(infos))
for _, info := range infos {
events[info.Event] = info
}
require.Contains(t, events, NotificationEmailEventAuthVerifyCode)
require.Contains(t, events, NotificationEmailEventAuthPasswordReset)
require.False(t, events[NotificationEmailEventAuthVerifyCode].Optional)
require.False(t, events[NotificationEmailEventAuthPasswordReset].Optional)
require.Contains(t, events[NotificationEmailEventAuthVerifyCode].Placeholders, "verification_code")
require.Contains(t, events[NotificationEmailEventAuthPasswordReset].Placeholders, "reset_url")
verifyPreview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{
Event: NotificationEmailEventAuthVerifyCode,
Locale: "zh-CN",
Variables: map[string]string{
"verification_code": "654321",
"expires_in_minutes": "15",
},
})
require.NoError(t, err)
require.Contains(t, verifyPreview.Subject, "邮箱验证码")
require.Contains(t, verifyPreview.HTML, "654321")
resetPreview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{
Event: NotificationEmailEventAuthPasswordReset,
Locale: "en",
Variables: map[string]string{
"reset_url": "https://example.com/reset?token=abc",
"expires_in_minutes": "30",
},
})
require.NoError(t, err)
require.Contains(t, resetPreview.Subject, "Password reset")
require.Contains(t, resetPreview.HTML, "https://example.com/reset?token=abc")
}
func TestNotificationEmailAdditionalEventsAreListedAndPreviewable(t *testing.T) {
ctx := context.Background()
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
infos := svc.ListEventInfos()
events := make(map[string]NotificationEmailEventInfo, len(infos))
for _, info := range infos {
events[info.Event] = info
}
checks := []struct {
event string
placeholder string
}{
{NotificationEmailEventNotificationEmailVerifyCode, "verification_code"},
{NotificationEmailEventAccountQuotaAlert, "account_name"},
{NotificationEmailEventContentModerationViolation, "moderation_category"},
{NotificationEmailEventContentModerationDisabled, "violation_count"},
{NotificationEmailEventOpsAlert, "rule_name"},
{NotificationEmailEventOpsScheduledReport, "report_html"},
}
for _, check := range checks {
info, ok := events[check.event]
require.Truef(t, ok, "expected %s to be listed", check.event)
require.False(t, info.Optional)
require.Contains(t, info.Placeholders, check.placeholder)
preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{Event: check.event, Locale: "zh"})
require.NoError(t, err)
require.NotEmpty(t, preview.Subject)
require.NotEmpty(t, preview.HTML)
}
}
func TestNotificationEmailRawHTMLVariablesAreTrustedOnlyForHTMLPlaceholders(t *testing.T) {
require.True(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsScheduledReport, "report_html"))
require.False(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsScheduledReport, "recipient_name"))
require.False(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsAlert, "report_html"))
preview, err := renderNotificationEmail(
NotificationEmailEventOpsScheduledReport,
"Report for {{recipient_name}}",
`<section>{{report_html}}</section><p>{{recipient_name}}</p>`,
map[string]string{
"recipient_name": `<script>alert("x")</script>`,
"report_html": `<p>escaped report</p>`,
},
map[string]string{
"report_html": `<table><tr><td>trusted report</td></tr></table>`,
},
)
require.NoError(t, err)
require.Contains(t, preview.HTML, `<table><tr><td>trusted report</td></tr></table>`)
require.NotContains(t, preview.HTML, `escaped report`)
require.Contains(t, preview.HTML, `&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;`)
require.Contains(t, preview.Subject, `<script>alert("x")</script>`)
preview, err = renderNotificationEmail(
NotificationEmailEventOpsScheduledReport,
"Recipient {{recipient_name}}",
`<p>{{recipient_name}}</p>`,
map[string]string{"recipient_name": `<em>escaped</em>`},
map[string]string{"recipient_name": `<strong>raw</strong>`},
)
require.NoError(t, err)
require.Contains(t, preview.HTML, `&lt;em&gt;escaped&lt;/em&gt;`)
require.NotContains(t, preview.HTML, `<strong>raw</strong>`)
}
func TestNotificationEmailFallbackClassification(t *testing.T) {
templateErr := notificationEmailTemplateErr(errors.New("bad template"))
configErr := notificationEmailConfigErr(errors.New("missing email service"))
deliveryErr := notificationEmailDeliveryErr(errors.New("smtp timeout"))
require.True(t, shouldFallbackNotificationEmail(templateErr))
require.True(t, shouldFallbackNotificationEmail(configErr))
require.False(t, shouldFallbackNotificationEmail(deliveryErr))
require.True(t, isNotificationEmailDeliveryError(deliveryErr))
require.False(t, isNotificationEmailDeliveryError(templateErr))
require.False(t, shouldFallbackNotificationEmail(nil))
}
func TestEmailQueueTasksPreserveLocaleHints(t *testing.T) {
queue := &EmailQueueService{taskChan: make(chan EmailTask, 2)}
require.NoError(t, queue.EnqueueVerifyCode("user@example.com", "Sub2API", "zh-CN"))
require.NoError(t, queue.EnqueuePasswordReset("user@example.com", "Sub2API", "https://example.com/reset", "en-US"))
verifyTask := <-queue.taskChan
require.Equal(t, TaskTypeVerifyCode, verifyTask.TaskType)
require.Equal(t, "zh-CN", verifyTask.Locale)
resetTask := <-queue.taskChan
require.Equal(t, TaskTypePasswordReset, resetTask.TaskType)
require.Equal(t, "en-US", resetTask.Locale)
}
func TestOpsScheduledReportDeliverySourceIDIncludesReportIdentity(t *testing.T) {
report := &opsScheduledReport{Name: "日报", ReportType: "daily_summary", Schedule: "0 9 * * *"}
sourceID := opsScheduledReportDeliverySourceID(report)
require.Contains(t, sourceID, "daily_summary")
require.Contains(t, sourceID, "日报")
require.Contains(t, sourceID, "0 9 * * *")
require.NotEqual(t, sourceID, opsScheduledReportDeliverySourceID(&opsScheduledReport{Name: "周报", ReportType: "weekly_summary", Schedule: "0 9 * * 1"}))
require.Equal(t, "scheduled_report", opsScheduledReportDeliverySourceID(nil))
}
func TestNotificationEmailUnsubscribeOnlyAllowsOptionalEvents(t *testing.T) {
ctx := context.Background()
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
token, err := svc.createUnsubscribeToken(ctx, "User@Example.com", NotificationEmailEventBalanceLow)
require.NoError(t, err)
result, err := svc.Unsubscribe(ctx, token)
require.NoError(t, err)
require.True(t, result.Done)
require.Equal(t, NotificationEmailEventBalanceLow, result.Event)
unsubscribed, err := svc.IsUnsubscribed(ctx, "user@example.com", NotificationEmailEventBalanceLow)
require.NoError(t, err)
require.True(t, unsubscribed)
transactionalToken, err := svc.createUnsubscribeToken(ctx, "user@example.com", NotificationEmailEventBalanceRechargeSuccess)
require.NoError(t, err)
_, err = svc.Unsubscribe(ctx, transactionalToken)
require.Error(t, err)
require.Contains(t, err.Error(), "transactional")
authToken, err := svc.createUnsubscribeToken(ctx, "user@example.com", NotificationEmailEventAuthVerifyCode)
require.NoError(t, err)
_, err = svc.Unsubscribe(ctx, authToken)
require.Error(t, err)
require.Contains(t, err.Error(), "transactional")
}
func TestNotificationEmailLocaleMemoryNormalizesAcceptLanguage(t *testing.T) {
ctx := context.Background()
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
svc.RememberRecipientLocale(ctx, 42, "User@Example.com", "zh-CN,zh;q=0.9,en;q=0.8")
require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 42, "user@example.com"))
require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 0, "user@example.com"))
}
type notificationEmailMemorySettingRepo struct {
mu sync.RWMutex
values map[string]string
}
func newNotificationEmailMemorySettingRepo() *notificationEmailMemorySettingRepo {
return &notificationEmailMemorySettingRepo{values: make(map[string]string)}
}
func (r *notificationEmailMemorySettingRepo) Get(_ context.Context, key string) (*Setting, error) {
r.mu.RLock()
defer r.mu.RUnlock()
value, ok := r.values[key]
if !ok {
return nil, ErrSettingNotFound
}
return &Setting{Key: key, Value: value}, nil
}
func (r *notificationEmailMemorySettingRepo) GetValue(ctx context.Context, key string) (string, error) {
setting, err := r.Get(ctx, key)
if err != nil {
return "", err
}
return setting.Value, nil
}
func (r *notificationEmailMemorySettingRepo) Set(_ context.Context, key, value string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.values[key] = value
return nil
}
func (r *notificationEmailMemorySettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
r.mu.RLock()
defer r.mu.RUnlock()
out := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := r.values[key]; ok {
out[key] = value
}
}
return out, nil
}
func (r *notificationEmailMemorySettingRepo) SetMultiple(_ context.Context, settings map[string]string) error {
r.mu.Lock()
defer r.mu.Unlock()
for key, value := range settings {
r.values[key] = value
}
return nil
}
func (r *notificationEmailMemorySettingRepo) GetAll(_ context.Context) (map[string]string, error) {
r.mu.RLock()
defer r.mu.RUnlock()
out := make(map[string]string, len(r.values))
for key, value := range r.values {
out[key] = value
}
return out, nil
}
func (r *notificationEmailMemorySettingRepo) Delete(_ context.Context, key string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.values[key]; !ok {
return ErrSettingNotFound
}
delete(r.values, key)
return nil
}
func TestNotificationEmailMemorySettingRepoSatisfiesInterface(t *testing.T) {
var _ SettingRepository = (*notificationEmailMemorySettingRepo)(nil)
require.False(t, strings.Contains(notificationEmailPreferenceKey(NotificationEmailEventBalanceLow, "User@Example.com"), "User@Example.com"))
}