A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
625 lines
14 KiB
Go
625 lines
14 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"net/smtp"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// MockSMTPSender is a mock SMTP sender for testing
|
|
type MockSMTPSender struct {
|
|
SentEmails []SentEmail
|
|
ShouldFail bool
|
|
FailError error
|
|
}
|
|
|
|
// SentEmail represents a sent email for testing
|
|
type SentEmail struct {
|
|
To []string
|
|
Subject string
|
|
Body string
|
|
}
|
|
|
|
// SendMail is a mock implementation of smtp.SendMail
|
|
func (m *MockSMTPSender) SendMail(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
|
if m.ShouldFail {
|
|
return m.FailError
|
|
}
|
|
|
|
// Parse the email to extract subject and body
|
|
msgStr := string(msg)
|
|
subject := extractSubject(msgStr)
|
|
|
|
m.SentEmails = append(m.SentEmails, SentEmail{
|
|
To: to,
|
|
Subject: subject,
|
|
Body: msgStr,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractSubject extracts the subject from an email message
|
|
func extractSubject(msg string) string {
|
|
lines := strings.Split(msg, "\r\n")
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "Subject: ") {
|
|
return strings.TrimPrefix(line, "Subject: ")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// TestEmailService_SendEmail tests basic email sending
|
|
func TestEmailService_SendEmail(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
to string
|
|
subject string
|
|
htmlBody string
|
|
textBody string
|
|
shouldFail bool
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "valid email",
|
|
to: "user@example.com",
|
|
subject: "Test Email",
|
|
htmlBody: "<h1>Hello</h1><p>World</p>",
|
|
textBody: "Hello\nWorld",
|
|
shouldFail: false,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "email with special characters",
|
|
to: "user+test@example.com",
|
|
subject: "Test: Öäü Special Characters",
|
|
htmlBody: "<p>Special: €£¥</p>",
|
|
textBody: "Special: €£¥",
|
|
shouldFail: false,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "SMTP failure",
|
|
to: "user@example.com",
|
|
subject: "Test",
|
|
htmlBody: "<p>Test</p>",
|
|
textBody: "Test",
|
|
shouldFail: true,
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Validate email format
|
|
isValidEmail := strings.Contains(tt.to, "@") && strings.Contains(tt.to, ".")
|
|
|
|
if !isValidEmail && !tt.expectError {
|
|
t.Error("Invalid email format should produce error")
|
|
}
|
|
|
|
// Validate subject is not empty
|
|
if tt.subject == "" && !tt.expectError {
|
|
t.Error("Empty subject should produce error")
|
|
}
|
|
|
|
// Validate body content exists
|
|
if (tt.htmlBody == "" && tt.textBody == "") && !tt.expectError {
|
|
t.Error("Both bodies empty should produce error")
|
|
}
|
|
|
|
// Simulate SMTP send
|
|
var err error
|
|
if tt.shouldFail {
|
|
err = fmt.Errorf("SMTP error: connection refused")
|
|
}
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Error("Expected error, got nil")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Errorf("Expected no error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailService_SendVerificationEmail tests verification email sending
|
|
func TestEmailService_SendVerificationEmail(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
to string
|
|
userName string
|
|
token string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "valid verification email",
|
|
to: "newuser@example.com",
|
|
userName: "Max Mustermann",
|
|
token: "abc123def456",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "user without name",
|
|
to: "user@example.com",
|
|
userName: "",
|
|
token: "token123",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "empty token",
|
|
to: "user@example.com",
|
|
userName: "Test User",
|
|
token: "",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "invalid email",
|
|
to: "invalid-email",
|
|
userName: "Test",
|
|
token: "token123",
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Validate inputs
|
|
var err error
|
|
if tt.token == "" {
|
|
err = &ValidationError{Field: "token", Message: "required"}
|
|
} else if !strings.Contains(tt.to, "@") {
|
|
err = &ValidationError{Field: "email", Message: "invalid format"}
|
|
}
|
|
|
|
// Build verification link
|
|
if tt.token != "" {
|
|
verifyLink := fmt.Sprintf("https://example.com/verify-email?token=%s", tt.token)
|
|
if verifyLink == "" {
|
|
t.Error("Verification link should not be empty")
|
|
}
|
|
|
|
// Verify link contains token
|
|
if !strings.Contains(verifyLink, tt.token) {
|
|
t.Error("Verification link should contain token")
|
|
}
|
|
}
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Error("Expected error, got nil")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Errorf("Expected no error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailService_SendPasswordResetEmail tests password reset email
|
|
func TestEmailService_SendPasswordResetEmail(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
to string
|
|
userName string
|
|
token string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "valid password reset",
|
|
to: "user@example.com",
|
|
userName: "John Doe",
|
|
token: "reset-token-123",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "empty token",
|
|
to: "user@example.com",
|
|
userName: "John Doe",
|
|
token: "",
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var err error
|
|
if tt.token == "" {
|
|
err = &ValidationError{Field: "token", Message: "required"}
|
|
}
|
|
|
|
// Build reset link
|
|
if tt.token != "" {
|
|
resetLink := fmt.Sprintf("https://example.com/reset-password?token=%s", tt.token)
|
|
|
|
// Verify link is secure (HTTPS)
|
|
if !strings.HasPrefix(resetLink, "https://") {
|
|
t.Error("Reset link should use HTTPS")
|
|
}
|
|
}
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Error("Expected error, got nil")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Errorf("Expected no error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailService_Send2FAEmail tests 2FA notification emails
|
|
func TestEmailService_Send2FAEmail(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
to string
|
|
action string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "2FA enabled notification",
|
|
to: "user@example.com",
|
|
action: "enabled",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "2FA disabled notification",
|
|
to: "user@example.com",
|
|
action: "disabled",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "invalid action",
|
|
to: "user@example.com",
|
|
action: "invalid",
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
validActions := map[string]bool{
|
|
"enabled": true,
|
|
"disabled": true,
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var err error
|
|
if !validActions[tt.action] {
|
|
err = &ValidationError{Field: "action", Message: "must be 'enabled' or 'disabled'"}
|
|
}
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Error("Expected error, got nil")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Errorf("Expected no error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailService_SendConsentReminderEmail tests consent reminder
|
|
func TestEmailService_SendConsentReminderEmail(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
to string
|
|
documentName string
|
|
daysLeft int
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "reminder with 7 days left",
|
|
to: "user@example.com",
|
|
documentName: "Terms of Service",
|
|
daysLeft: 7,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "reminder with 1 day left",
|
|
to: "user@example.com",
|
|
documentName: "Privacy Policy",
|
|
daysLeft: 1,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "urgent reminder - overdue",
|
|
to: "user@example.com",
|
|
documentName: "Terms",
|
|
daysLeft: 0,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "empty document name",
|
|
to: "user@example.com",
|
|
documentName: "",
|
|
daysLeft: 7,
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var err error
|
|
if tt.documentName == "" {
|
|
err = &ValidationError{Field: "document name", Message: "required"}
|
|
}
|
|
|
|
// Check urgency level
|
|
var urgency string
|
|
if tt.daysLeft <= 0 {
|
|
urgency = "critical"
|
|
} else if tt.daysLeft <= 3 {
|
|
urgency = "urgent"
|
|
} else {
|
|
urgency = "normal"
|
|
}
|
|
|
|
if urgency == "" && !tt.expectError {
|
|
t.Error("Urgency should be set")
|
|
}
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Error("Expected error, got nil")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Errorf("Expected no error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailService_MIMEFormatting tests MIME message formatting
|
|
func TestEmailService_MIMEFormatting(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
htmlBody string
|
|
textBody string
|
|
checkFor []string
|
|
}{
|
|
{
|
|
name: "multipart alternative",
|
|
htmlBody: "<h1>Test</h1>",
|
|
textBody: "Test",
|
|
checkFor: []string{
|
|
"MIME-Version: 1.0",
|
|
"Content-Type: multipart/alternative",
|
|
"Content-Type: text/plain",
|
|
"Content-Type: text/html",
|
|
},
|
|
},
|
|
{
|
|
name: "UTF-8 encoding",
|
|
htmlBody: "<p>Öäü</p>",
|
|
textBody: "Öäü",
|
|
checkFor: []string{
|
|
"charset=\"UTF-8\"",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Build MIME message (simplified)
|
|
message := fmt.Sprintf("MIME-Version: 1.0\r\n"+
|
|
"Content-Type: multipart/alternative; boundary=\"boundary\"\r\n"+
|
|
"\r\n"+
|
|
"--boundary\r\n"+
|
|
"Content-Type: text/plain; charset=\"UTF-8\"\r\n"+
|
|
"\r\n%s\r\n"+
|
|
"--boundary\r\n"+
|
|
"Content-Type: text/html; charset=\"UTF-8\"\r\n"+
|
|
"\r\n%s\r\n"+
|
|
"--boundary--\r\n",
|
|
tt.textBody, tt.htmlBody)
|
|
|
|
// Verify required headers are present
|
|
for _, required := range tt.checkFor {
|
|
if !strings.Contains(message, required) {
|
|
t.Errorf("Message should contain '%s'", required)
|
|
}
|
|
}
|
|
|
|
// Verify both bodies are included
|
|
if !strings.Contains(message, tt.textBody) {
|
|
t.Error("Message should contain text body")
|
|
}
|
|
if !strings.Contains(message, tt.htmlBody) {
|
|
t.Error("Message should contain HTML body")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailService_TemplateRendering tests email template rendering
|
|
func TestEmailService_TemplateRendering(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
template string
|
|
variables map[string]string
|
|
expectVars []string
|
|
}{
|
|
{
|
|
name: "verification template",
|
|
template: "verification",
|
|
variables: map[string]string{
|
|
"Name": "John Doe",
|
|
"VerifyLink": "https://example.com/verify?token=abc",
|
|
},
|
|
expectVars: []string{"John Doe", "https://example.com/verify?token=abc"},
|
|
},
|
|
{
|
|
name: "password reset template",
|
|
template: "password_reset",
|
|
variables: map[string]string{
|
|
"Name": "Jane Smith",
|
|
"ResetLink": "https://example.com/reset?token=xyz",
|
|
},
|
|
expectVars: []string{"Jane Smith", "https://example.com/reset?token=xyz"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Simulate template rendering
|
|
rendered := fmt.Sprintf("Hello %s, please visit %s",
|
|
tt.variables["Name"],
|
|
getLink(tt.variables))
|
|
|
|
// Verify all variables are in rendered output
|
|
for _, expectedVar := range tt.expectVars {
|
|
if !strings.Contains(rendered, expectedVar) {
|
|
t.Errorf("Rendered template should contain '%s'", expectedVar)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailService_EmailValidation tests email address validation
|
|
func TestEmailService_EmailValidation(t *testing.T) {
|
|
tests := []struct {
|
|
email string
|
|
isValid bool
|
|
}{
|
|
{"user@example.com", true},
|
|
{"user+tag@example.com", true},
|
|
{"user.name@example.co.uk", true},
|
|
{"user@subdomain.example.com", true},
|
|
{"invalid", false},
|
|
{"@example.com", false},
|
|
{"user@", false},
|
|
{"user@.com", false},
|
|
{"", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.email, func(t *testing.T) {
|
|
// RFC 5322 compliant email validation pattern
|
|
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
|
isValid := emailRegex.MatchString(tt.email)
|
|
|
|
if isValid != tt.isValid {
|
|
t.Errorf("Email %s: expected valid=%v, got %v", tt.email, tt.isValid, isValid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailService_SMTPConfig tests SMTP configuration
|
|
func TestEmailService_SMTPConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config EmailConfig
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "valid config",
|
|
config: EmailConfig{
|
|
Host: "smtp.example.com",
|
|
Port: 587,
|
|
Username: "user@example.com",
|
|
Password: "password",
|
|
FromName: "BreakPilot",
|
|
FromAddr: "noreply@example.com",
|
|
BaseURL: "https://example.com",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "missing host",
|
|
config: EmailConfig{
|
|
Port: 587,
|
|
Username: "user@example.com",
|
|
Password: "password",
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "invalid port",
|
|
config: EmailConfig{
|
|
Host: "smtp.example.com",
|
|
Port: 0,
|
|
Username: "user@example.com",
|
|
Password: "password",
|
|
},
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var err error
|
|
if tt.config.Host == "" {
|
|
err = &ValidationError{Field: "host", Message: "required"}
|
|
} else if tt.config.Port <= 0 || tt.config.Port > 65535 {
|
|
err = &ValidationError{Field: "port", Message: "must be between 1 and 65535"}
|
|
}
|
|
|
|
if tt.expectError && err == nil {
|
|
t.Error("Expected error, got nil")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Errorf("Expected no error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailService_RateLimiting tests email rate limiting logic
|
|
func TestEmailService_RateLimiting(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
emailsSent int
|
|
timeWindow int // minutes
|
|
limit int
|
|
expectThrottle bool
|
|
}{
|
|
{
|
|
name: "under limit",
|
|
emailsSent: 5,
|
|
timeWindow: 60,
|
|
limit: 10,
|
|
expectThrottle: false,
|
|
},
|
|
{
|
|
name: "at limit",
|
|
emailsSent: 10,
|
|
timeWindow: 60,
|
|
limit: 10,
|
|
expectThrottle: false,
|
|
},
|
|
{
|
|
name: "over limit",
|
|
emailsSent: 15,
|
|
timeWindow: 60,
|
|
limit: 10,
|
|
expectThrottle: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
shouldThrottle := tt.emailsSent > tt.limit
|
|
|
|
if shouldThrottle != tt.expectThrottle {
|
|
t.Errorf("Expected throttle=%v, got %v", tt.expectThrottle, shouldThrottle)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func getLink(vars map[string]string) string {
|
|
if link, ok := vars["VerifyLink"]; ok {
|
|
return link
|
|
}
|
|
if link, ok := vars["ResetLink"]; ok {
|
|
return link
|
|
}
|
|
return ""
|
|
}
|