Files
paliad/internal/services/invite_service_test.go
m 11217f7bfa feat: email service — SMTP + deadline reminders + invitations (t-paliad-021)
- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
  html/template rendering, branded base layout + content templates, silent
  no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
  overdue / due tomorrow / due within the week (Monday digest). Dedup via
  paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
  whitelist, in-memory 10/day/user rate limit, audit row in
  paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
  question; current pilot keeps identity mails on Supabase default sender.

Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.
2026-04-20 12:34:38 +02:00

109 lines
3.1 KiB
Go

package services
import (
"testing"
"time"
"github.com/google/uuid"
)
// allowInviteTestService builds a bare InviteService that exercises the
// rate-limiter without touching SQL or SMTP. The DB and mail fields stay
// nil — only the counter logic is under test here.
func allowInviteTestService() *InviteService {
return &InviteService{
allowedDomains: func() []string { return []string{"hlc.com"} },
sentBy: map[uuid.UUID][]time.Time{},
clock: func() time.Time { return time.Now() },
}
}
// Fills the slot up to the cap, then asserts the next call is refused.
func TestInviteRateLimit_WithinWindow(t *testing.T) {
s := allowInviteTestService()
uid := uuid.New()
for i := 0; i < InviteRateLimit; i++ {
if !s.allowInvite(uid) {
t.Fatalf("slot %d should succeed", i)
}
}
if s.allowInvite(uid) {
t.Fatal("slot beyond cap should be denied")
}
}
// After the window rolls over, old stamps are dropped and the user is
// allowed to send again. This is the main invariant behind the 24h cap.
func TestInviteRateLimit_WindowRollover(t *testing.T) {
s := allowInviteTestService()
uid := uuid.New()
now := time.Date(2026, 4, 20, 10, 0, 0, 0, time.UTC)
s.clock = func() time.Time { return now }
for i := 0; i < InviteRateLimit; i++ {
if !s.allowInvite(uid) {
t.Fatalf("slot %d should succeed at t=now", i)
}
}
if s.allowInvite(uid) {
t.Fatal("11th slot should be denied at t=now")
}
// Jump well past the window — all stamps should age out.
s.clock = func() time.Time { return now.Add(InviteRateWindow + time.Minute) }
if !s.allowInvite(uid) {
t.Fatal("slot after rollover should succeed")
}
if got := s.RemainingToday(uid); got != InviteRateLimit-1 {
t.Errorf("RemainingToday after rollover = %d, want %d", got, InviteRateLimit-1)
}
}
// Concurrent senders must not be able to double-spend the last slot — the
// limiter holds the mutex across the check-and-insert.
func TestInviteRateLimit_Concurrent(t *testing.T) {
s := allowInviteTestService()
uid := uuid.New()
done := make(chan bool, InviteRateLimit*2)
for i := 0; i < InviteRateLimit*2; i++ {
go func() { done <- s.allowInvite(uid) }()
}
granted := 0
for i := 0; i < InviteRateLimit*2; i++ {
if <-done {
granted++
}
}
if granted != InviteRateLimit {
t.Errorf("granted=%d, want %d", granted, InviteRateLimit)
}
}
// domainAllowed must match case-insensitively and fail-closed when the
// whitelist is empty.
func TestDomainAllowed(t *testing.T) {
s := &InviteService{allowedDomains: func() []string { return []string{"hlc.com", "HLC.de"} }}
allow := map[string]bool{
"alice@hlc.com": true,
"alice@HLC.COM": true,
"alice@hlc.de": true,
"alice@hlc.com.evil.ru": false,
"alice@example.org": false,
"malformed": false,
}
for addr, want := range allow {
if got := s.domainAllowed(addr); got != want {
t.Errorf("domainAllowed(%q) = %v, want %v", addr, got, want)
}
}
// Empty whitelist = deny everything.
s.allowedDomains = func() []string { return nil }
if s.domainAllowed("alice@hlc.com") {
t.Error("empty whitelist should deny")
}
}