- 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.
109 lines
3.1 KiB
Go
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")
|
|
}
|
|
}
|