#49 — adds a third "Konto direkt anlegen" path on /admin/team alongside "Onboard existing" and "Invite colleague". Creates both auth.users (via Supabase Admin API) and paliad.users in one click; new user is visible in dropdowns immediately and receives a paliad-branded magic-link email. - internal/services/supabase_admin.go: new SupabaseAdminClient — thin net/http shim. 3 methods (CreateAuthUser, GenerateRecoveryLink, DeleteAuthUser). 10s timeout. ErrSupabaseAdminUnavailable when key unset, ErrSupabaseEmailExists when 422-with-"already" returned. apikey + Bearer headers on every call. Sentinel errors for handler mapping. - internal/services/supabase_admin_test.go: 5 tests pin wire-shape (disabled mode, happy-path POST + headers + body, email-exists mapping, both action-link response shapes, DELETE-by-id route). - internal/services/user_service.go: UserService grows optional supabase + mail + baseURL dependencies via SetAddUserDeps. AdminCreateFullInput (email/display_name/office/job_title/profession/lang/send_welcome_mail + inviter fields). AdminCreateUserFull validates input → calls supabase.CreateAuthUser → inserts paliad.users (best-effort DeleteAuthUser rollback on insert fail) → writes paliad.system_audit_log row (event_type='user.added_by_admin') → sends welcome mail with magic-link (best-effort). - internal/templates/email/add_user_welcome.{de,en}.html: new template with magic-link CTA + base-URL fallback + firm-name placeholder. Editable through the existing /admin/email-templates editor (admin-overridable via DB). - internal/services/email_template_*.go: register 'add_user_welcome' as a fourth canonical key, defaultSubjects entry, sample data, variable contract (6 vars). - internal/services/mail_service_test.go: TestRenderTemplateAddUserWelcome pins both langs render with magic-link + firm + matching subject. - internal/handlers/admin_users.go: handleAdminCreateFullUser POST /api/admin/users/full. Fills inviter fields from auth.uid() server-side (never trusts the request body). Error map: 503 (unavailable), 409 (email exists / already onboarded), 400 (invalid input), 403 (domain not on whitelist), 500 (other). - internal/handlers/handlers.go: route registered behind adminGate. - cmd/server/main.go: LoadSupabaseAdminClient + users.SetAddUserDeps + boot-log line so the deployer knows whether the path is active. - frontend/src/admin-team.tsx: "Konto direkt anlegen" button + admin-add-full-modal with email/name/office/profession/job_title/lang fields + send-welcome checkbox (default on). - frontend/src/client/admin-team.ts: initAddFullModal — POST to /api/admin/users/full, inline error handling for 503 / 409 / generic, optimistic insert into users[] on success, name auto-fills from email local-part on blur. - i18n: +20 keys (admin.team.add.full + admin.team.add_full.*) × DE + EN. Design picks honoured: Supabase Admin API path (Q1), welcome email default on (Q2), two-step with best-effort rollback (Q3), job_title default 'Associate' (Q4), profession default 'associate' (Q5). Trade-off #3 from §6 (privileged credential broadens trust surface) accepted by m via head. go build && go test -short ./internal/... + bun run build all green.
246 lines
7.9 KiB
Go
246 lines
7.9 KiB
Go
package services
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestHTMLToText covers the HTML→plain-text fallback. Users with text-only
|
|
// clients still need to read reminder/invite mails, and some spam filters
|
|
// downrank multipart/alternative when the text part is empty or identical
|
|
// to the HTML.
|
|
func TestHTMLToText(t *testing.T) {
|
|
in := `<html><head><style>b{color:red}</style></head><body>` +
|
|
`<h1>Deadline überfällig</h1><p>Hallo <b>Welt</b></p>` +
|
|
`<p>Zweite Zeile — ok.</p><script>alert(1)</script></body></html>`
|
|
got := htmlToText(in)
|
|
if !strings.Contains(got, "Deadline überfällig") {
|
|
t.Errorf("expected decoded umlauts in %q", got)
|
|
}
|
|
if strings.Contains(got, "alert(1)") {
|
|
t.Errorf("script content leaked into text body: %q", got)
|
|
}
|
|
if strings.Contains(got, "<b>") {
|
|
t.Errorf("raw tag remained in text body: %q", got)
|
|
}
|
|
if !strings.Contains(got, "—") {
|
|
t.Errorf("expected em-dash decoded, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestRenderTemplateDeadlineDigest verifies the bundled-digest template
|
|
// renders all three category sections, applies the DRINGEND wording on the
|
|
// evening slot, and folds in IsOtherOwner labels when a row's owner isn't
|
|
// the recipient. A typo in deadline_digest.de.html would fail here before
|
|
// any SMTP I/O.
|
|
//
|
|
// Also asserts that the rendered subject picks up the evening DRINGEND
|
|
// framing — the SLO-critical phrasing must survive the template-render
|
|
// path, not just the body.
|
|
func TestRenderTemplateDeadlineDigest(t *testing.T) {
|
|
svc, err := NewMailService()
|
|
if err != nil {
|
|
t.Fatalf("NewMailService: %v", err)
|
|
}
|
|
subject, html, err := svc.RenderTemplate(TemplateData{
|
|
Lang: "de",
|
|
Name: "deadline_digest",
|
|
Data: map[string]any{
|
|
"Slot": "evening",
|
|
"IsEvening": true,
|
|
"OverdueCount": 0,
|
|
"DueTodayCount": 1,
|
|
"DueWarningCount": 0,
|
|
"OpenTotal": 1,
|
|
"DueToday": []map[string]any{
|
|
{
|
|
"DueDate": "2026-04-28",
|
|
"Title": "Heute fällig",
|
|
"ProjectReference": "2026/0002",
|
|
"ProjectTitle": "Acme v Gadget",
|
|
"OwnerName": "Self",
|
|
"IsOtherOwner": false,
|
|
"URL": "https://paliad.de/deadlines/b",
|
|
},
|
|
},
|
|
"DeadlinesURL": "https://paliad.de/deadlines",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RenderTemplate: %v", err)
|
|
}
|
|
wants := []string{
|
|
"Paliad",
|
|
"DRINGEND", // evening framing on the due_today section
|
|
"Heute fällig", // row title (passed through as data)
|
|
"2026/0002",
|
|
"Acme v Gadget",
|
|
"https://paliad.de/deadlines/b", // due_today link
|
|
"https://paliad.de/deadlines", // CTA
|
|
}
|
|
for _, want := range wants {
|
|
if !strings.Contains(html, want) {
|
|
t.Errorf("rendered html missing %q", want)
|
|
}
|
|
}
|
|
// "Self" owner name should NOT appear because IsOtherOwner=false suppresses
|
|
// the owner line.
|
|
if strings.Contains(html, "Self") {
|
|
t.Errorf("rendered html should not show OwnerName when IsOtherOwner=false: %q", html)
|
|
}
|
|
// The DE evening, no-overdue, due_today=1 path should render
|
|
// "DRINGEND — 1 heute noch offen".
|
|
wantSubject := "[Paliad] DRINGEND — 1 heute noch offen"
|
|
if subject != wantSubject {
|
|
t.Errorf("subject got %q, want %q", subject, wantSubject)
|
|
}
|
|
}
|
|
|
|
// TestRenderTemplateDeadlineDigestSystemausfall covers the worst-case
|
|
// subject: evening slot with overdue rows. Must produce SYSTEMAUSFALL
|
|
// framing in DE (and SYSTEM FAILURE in EN — covered alongside).
|
|
func TestRenderTemplateDeadlineDigestSystemausfall(t *testing.T) {
|
|
svc, _ := NewMailService()
|
|
for _, tc := range []struct {
|
|
name string
|
|
lang string
|
|
wantSubject string
|
|
}{
|
|
{"de evening overdue", "de", "[Paliad] SYSTEMAUSFALL: 2 überfällig — plus 1 heute offen"},
|
|
{"en evening overdue", "en", "[Paliad] SYSTEM FAILURE: 2 overdue — plus 1 still open today"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
subject, _, err := svc.RenderTemplate(TemplateData{
|
|
Lang: tc.lang,
|
|
Name: "deadline_digest",
|
|
Data: map[string]any{
|
|
"Slot": "evening",
|
|
"IsEvening": true,
|
|
"OverdueCount": 2,
|
|
"DueTodayCount": 1,
|
|
"DueWarningCount": 0,
|
|
"OpenTotal": 1,
|
|
"Overdue": []map[string]any{{}, {}},
|
|
"DueToday": []map[string]any{{}},
|
|
"DeadlinesURL": "https://paliad.de/deadlines",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RenderTemplate: %v", err)
|
|
}
|
|
if subject != tc.wantSubject {
|
|
t.Errorf("subject got %q, want %q", subject, tc.wantSubject)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRenderTemplateInvitation covers the invitation template so a typo in
|
|
// invitation.en.html would fail CI.
|
|
func TestRenderTemplateInvitation(t *testing.T) {
|
|
svc, err := NewMailService()
|
|
if err != nil {
|
|
t.Fatalf("NewMailService: %v", err)
|
|
}
|
|
subject, html, err := svc.RenderTemplate(TemplateData{
|
|
Lang: "en",
|
|
Name: "invitation",
|
|
Data: map[string]any{
|
|
"InviterName": "Anna Schmidt",
|
|
"InviterEmail": "anna@hlc.com",
|
|
"ToEmail": "colleague@hlc.com",
|
|
"Message": "Have a look at Paliad.",
|
|
"RegisterURL": "https://paliad.de/login",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RenderTemplate: %v", err)
|
|
}
|
|
for _, want := range []string{
|
|
"Anna Schmidt", "invites you", "Have a look at Paliad.",
|
|
"https://paliad.de/login", "colleague@hlc.com",
|
|
// Branding placeholder: {{.Firm}} should resolve to the configured
|
|
// firm name (defaults to "HLC"). Catches accidental deletion of the
|
|
// template placeholder when nobody set FIRM_NAME in the test env.
|
|
"platform for HLC",
|
|
} {
|
|
if !strings.Contains(html, want) {
|
|
t.Errorf("rendered html missing %q", want)
|
|
}
|
|
}
|
|
if subject != "[Paliad] Anna Schmidt invites you to Paliad" {
|
|
t.Errorf("subject got %q", subject)
|
|
}
|
|
}
|
|
|
|
// TestRenderTemplateAddUserWelcome — t-paliad-223 Slice B (#49). Catches
|
|
// a typo in either add_user_welcome.{de,en}.html: the rendered body must
|
|
// contain the inviter, the magic-link, the firm name, and the localised
|
|
// fallback subject from defaultSubjects must look right.
|
|
func TestRenderTemplateAddUserWelcome(t *testing.T) {
|
|
svc, err := NewMailService()
|
|
if err != nil {
|
|
t.Fatalf("NewMailService: %v", err)
|
|
}
|
|
for _, lang := range []string{"de", "en"} {
|
|
t.Run(lang, func(t *testing.T) {
|
|
subject, html, err := svc.RenderTemplate(TemplateData{
|
|
Lang: lang,
|
|
Name: EmailTemplateKeyAddUserWelcome,
|
|
Data: map[string]any{
|
|
"InviterName": "Maria Schmidt",
|
|
"InviterEmail": "maria@hlc.com",
|
|
"ToEmail": "neu.kollege@hlc.de",
|
|
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=TESTTOKEN",
|
|
"BaseURL": "https://paliad.de",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RenderTemplate: %v", err)
|
|
}
|
|
for _, want := range []string{
|
|
"Maria Schmidt", "neu.kollege@hlc.de",
|
|
"https://supabase.paliad.de/auth/v1/verify?token=TESTTOKEN",
|
|
"https://paliad.de/login",
|
|
// {{.Firm}} placeholder must render — branding default is "HLC".
|
|
"HLC",
|
|
} {
|
|
if !strings.Contains(html, want) {
|
|
t.Errorf("[%s] rendered html missing %q", lang, want)
|
|
}
|
|
}
|
|
wantSubject := "[Paliad] Ihr Paliad-Konto ist bereit"
|
|
if lang == "en" {
|
|
wantSubject = "[Paliad] Your Paliad account is ready"
|
|
}
|
|
if subject != wantSubject {
|
|
t.Errorf("[%s] subject got %q, want %q", lang, subject, wantSubject)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildMIMEHasBothParts ensures the multipart/alternative structure
|
|
// carries both the text and HTML parts — an earlier refactor dropped one
|
|
// part by mistake, caught by this.
|
|
func TestBuildMIMEHasBothParts(t *testing.T) {
|
|
msg := buildMIME("mail@paliad.de", "Paliad", "to@example.com",
|
|
"Test", "<p>HTML</p>", "TEXT")
|
|
body := string(msg)
|
|
if !strings.Contains(body, "Content-Type: text/plain") {
|
|
t.Error("missing text/plain part")
|
|
}
|
|
if !strings.Contains(body, "Content-Type: text/html") {
|
|
t.Error("missing text/html part")
|
|
}
|
|
if !strings.Contains(body, "multipart/alternative") {
|
|
t.Error("not multipart/alternative")
|
|
}
|
|
if !strings.Contains(body, "TEXT") {
|
|
t.Error("text body missing")
|
|
}
|
|
if !strings.Contains(body, "<p>HTML</p>") {
|
|
t.Error("html body missing")
|
|
}
|
|
}
|