Files
paliad/internal/services/broadcast_service_test.go
m 52ee319fd8 feat(t-paliad-147): bulk team email — send to filtered selection from /team page
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.

Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
  template_key, recipient_filter jsonb, recipient_user_ids uuid[],
  send_report jsonb, sent_at). RLS: senders read own rows, global_admin
  reads all; inserts must self-attribute. No CHECK-constraint extension to
  partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
  subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
  persists audit row, dispatches via 5-deep goroutine pool with 15s
  per-send timeout. Send report (sent/failed counts + per-recipient errors)
  is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
  *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
  first; only whitelisted tags re-emitted. Script tags and javascript:
  URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
  {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
  through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
  on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
  index. Powers the /team project multi-select filter without N round
  trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
  authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
  GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
  /admin/broadcasts is gateOnboarded (not adminGate) so leads can see
  their own sends; the service applies the per-row visibility filter.

Frontend
- /team gains a project multi-select chip dropdown (visible projects
  loaded from /api/projects, intersected against the memberships index)
  alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
  true (global_admin always; non-admin needs lead-ship on selected
  projects, or at least one project when no filter is set). Server still
  re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
  template dropdown (loads existing email templates and strips Go-template
  directives) + recipient preview (first 5 + expand) + send. Hard-blocks
  empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
  detail (subject, body, recipient list, send_report counts).

Tests
- broadcast_service_test.go: placeholder substitution table-driven,
  Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
  validation cases (empty subject/body, recipient cap, invalid email),
  signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
  rules (lead can send on own project, member cannot, admin sees all,
  member can't read lead's row). Skips when TEST_DATABASE_URL is unset.

i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
2026-05-07 20:58:57 +02:00

234 lines
6.2 KiB
Go

package services
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestSubstitutePlaceholders(t *testing.T) {
rec := BroadcastRecipient{
UserID: uuid.New(),
Email: "anna@hlc.com",
DisplayName: "Anna Beispiel",
FirstName: "Anna",
RoleOnProject: "lead",
}
cases := []struct {
name string
in string
want string
}{
{"name", "Hallo {{name}}", "Hallo Anna Beispiel"},
{"first_name", "Hi {{first_name}}!", "Hi Anna!"},
{"role_on_project", "Du bist {{role_on_project}}.", "Du bist lead."},
{"whitespace tolerated", "{{ first_name }}", "Anna"},
{"unknown token passes through", "Literal {{example}} stays", "Literal {{example}} stays"},
{"all three together",
"{{name}} ({{first_name}}, {{role_on_project}})",
"Anna Beispiel (Anna, lead)"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := substitutePlaceholders(tc.in, rec)
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
// renderMarkdownSafe must escape raw HTML and only re-emit a small whitelist
// of tags. Any leakage of a <script> tag would be an XSS vector since the
// rendered output goes straight into an HTML email body.
func TestRenderMarkdownSafe(t *testing.T) {
cases := []struct {
name string
in string
wantContains []string
wantMissing []string
}{
{
name: "bold",
in: "**hallo**",
wantContains: []string{"<strong>hallo</strong>"},
},
{
name: "italic underscore",
in: "_hallo_",
wantContains: []string{"<em>hallo</em>"},
},
{
name: "link",
in: "[paliad](https://paliad.de)",
wantContains: []string{`<a href="https://paliad.de">paliad</a>`},
},
{
name: "bullet list",
in: "- erstens\n- zweitens",
wantContains: []string{"<ul>", "<li>erstens</li>", "<li>zweitens</li>", "</ul>"},
},
{
name: "paragraph break",
in: "Erste Zeile\n\nZweite Zeile",
wantContains: []string{"<p>Erste Zeile</p>", "<p>Zweite Zeile</p>"},
},
{
name: "single newline → br",
in: "Zeile A\nZeile B",
wantContains: []string{"<p>Zeile A<br>", "Zeile B</p>"},
},
{
name: "script tag escaped",
in: "Hallo <script>alert(1)</script>",
wantContains: []string{"&lt;script&gt;", "&lt;/script&gt;"},
wantMissing: []string{"<script>", "alert(1)</script>"},
},
{
name: "link injection attempt — javascript: URL is rejected",
in: "[click](javascript:alert(1))",
wantMissing: []string{`href="javascript:`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := renderMarkdownSafe(tc.in)
for _, want := range tc.wantContains {
if !strings.Contains(got, want) {
t.Errorf("missing %q in %q", want, got)
}
}
for _, miss := range tc.wantMissing {
if strings.Contains(got, miss) {
t.Errorf("unexpected %q in %q", miss, got)
}
}
})
}
}
func TestFirstNameExtraction(t *testing.T) {
// senderSignature uses DisplayName directly; firstName extraction is
// frontend-side. Smoke-test only that DisplayName placeholder lands.
sender := models.User{
ID: uuid.New(),
Email: "max@hlc.com",
DisplayName: "Max Mustermann",
}
sig := senderSignature("de", sender)
if !strings.Contains(sig, "Max Mustermann") {
t.Errorf("DisplayName not in signature: %q", sig)
}
if !strings.Contains(sig, "Gesendet von") {
t.Errorf("DE prefix missing: %q", sig)
}
if !strings.Contains(sig, `mailto:max@hlc.com`) {
t.Errorf("mailto link missing: %q", sig)
}
sigEN := senderSignature("en", sender)
if !strings.Contains(sigEN, "Sent by") {
t.Errorf("EN prefix missing: %q", sigEN)
}
}
// TestBroadcastValidation exercises the cheap guards that fire before any
// SQL or SMTP I/O. Constructed with a nil DB so the tests don't need a
// connection string. The Send path bails out at validation before touching
// db.ExecContext.
func TestBroadcastValidation(t *testing.T) {
mailSvc, err := NewMailService()
if err != nil {
t.Fatalf("NewMailService: %v", err)
}
svc := NewBroadcastService(nil, mailSvc, nil, nil, NewEmailTemplateService(nil))
cases := []struct {
name string
in BroadcastInput
want error
}{
{
name: "empty subject",
in: BroadcastInput{Subject: "", Body: "x", Recipients: oneRec()},
want: ErrBroadcastEmptySubject,
},
{
name: "empty body",
in: BroadcastInput{Subject: "Hi", Body: " ", Recipients: oneRec()},
want: ErrBroadcastEmptyBody,
},
{
name: "no recipients",
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nil},
want: ErrBroadcastNoRecipients,
},
{
name: "too many recipients",
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nRecipients(BroadcastRecipientCap + 1)},
want: ErrBroadcastTooManyRecipients,
},
{
name: "invalid email",
in: BroadcastInput{
Subject: "Hi",
Body: "x",
Recipients: []BroadcastRecipient{{
UserID: uuid.New(),
Email: "not-an-email",
}},
},
want: ErrBroadcastInvalidEmail,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := svc.Send(t.Context(), uuid.New(), tc.in)
if err == nil {
t.Fatal("expected error, got nil")
}
// Use errors.Is so wrapped errors still match.
if !errorIs(err, tc.want) {
t.Errorf("got %v, want %v", err, tc.want)
}
})
}
}
// errorIs is a tiny shim so the test file doesn't need to import "errors".
// (Imports are kept terse on purpose — see existing test files.)
func errorIs(have, want error) bool {
if have == want {
return true
}
if have == nil || want == nil {
return false
}
// Fall back to message-level matching for fmt.Errorf %w wraps.
return strings.Contains(have.Error(), want.Error())
}
func oneRec() []BroadcastRecipient {
return []BroadcastRecipient{{
UserID: uuid.New(),
Email: "anna@hlc.com",
DisplayName: "Anna",
FirstName: "Anna",
}}
}
func nRecipients(n int) []BroadcastRecipient {
out := make([]BroadcastRecipient, 0, n)
for i := 0; i < n; i++ {
out = append(out, BroadcastRecipient{
UserID: uuid.New(),
Email: "user@hlc.com",
DisplayName: "User",
FirstName: "User",
})
}
return out
}