Files
breakpilot-lehrer/edu-search-service/internal/policy/policy_test.go
Benjamin Boenisch 414e0f5ec0
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 28s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Successful in 1m45s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 21s
feat: edu-search-service migriert, voice-service/geo-service entfernt
- edu-search-service von breakpilot-pwa nach breakpilot-lehrer kopiert (ohne vendor)
- opensearch + edu-search-service in docker-compose.yml hinzugefuegt
- voice-service aus docker-compose.yml entfernt (jetzt in breakpilot-core)
- geo-service aus docker-compose.yml entfernt (nicht mehr benoetigt)
- CI/CD: edu-search-service zu Gitea Actions und Woodpecker hinzugefuegt
  (Go lint, test mit go mod download, build, SBOM)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 18:36:38 +01:00

490 lines
12 KiB
Go

package policy
import (
"regexp"
"testing"
)
// =============================================================================
// MODEL TESTS
// =============================================================================
func TestBundeslandValidation(t *testing.T) {
tests := []struct {
name string
bl Bundesland
expected bool
}{
{"valid NI", BundeslandNI, true},
{"valid BY", BundeslandBY, true},
{"valid BW", BundeslandBW, true},
{"valid NW", BundeslandNW, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
found := false
for _, valid := range ValidBundeslaender {
if valid == tt.bl {
found = true
break
}
}
if found != tt.expected {
t.Errorf("Expected %v to be valid=%v, got valid=%v", tt.bl, tt.expected, found)
}
})
}
}
func TestLicenseValues(t *testing.T) {
licenses := []License{
LicenseDLDEBY20,
LicenseCCBY,
LicenseCCBYSA,
LicenseCC0,
LicenseParagraph5,
}
for _, l := range licenses {
if l == "" {
t.Errorf("License should not be empty")
}
}
}
func TestOperationValues(t *testing.T) {
if len(ValidOperations) != 4 {
t.Errorf("Expected 4 operations, got %d", len(ValidOperations))
}
expectedOps := []Operation{OperationLookup, OperationRAG, OperationTraining, OperationExport}
for _, expected := range expectedOps {
found := false
for _, op := range ValidOperations {
if op == expected {
found = true
break
}
}
if !found {
t.Errorf("Expected operation %s not found in ValidOperations", expected)
}
}
}
// =============================================================================
// PII DETECTOR TESTS
// =============================================================================
func TestPIIDetector_EmailDetection(t *testing.T) {
tests := []struct {
name string
text string
hasEmail bool
}{
{"simple email", "Contact: test@example.com", true},
{"email with plus", "Email: user+tag@domain.org", true},
{"no email", "This is plain text", false},
{"partial email", "user@ is not an email", false},
{"multiple emails", "Send to a@b.com and x@y.de", true},
}
// Test using regex pattern directly since we don't have a store
emailPattern := `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simple test without database
rule := &PIIRule{
Name: "Email",
RuleType: PIIRuleTypeRegex,
Pattern: emailPattern,
Severity: PIISeverityBlock,
}
detector := &PIIDetector{
compiledRules: make(map[string]*regexp.Regexp),
}
matches := detector.findMatches(tt.text, rule)
hasMatch := len(matches) > 0
if hasMatch != tt.hasEmail {
t.Errorf("Expected hasEmail=%v, got %v for text: %s", tt.hasEmail, hasMatch, tt.text)
}
})
}
}
func TestPIIDetector_PhoneDetection(t *testing.T) {
tests := []struct {
name string
text string
hasPhone bool
}{
{"german mobile", "Call +49 170 1234567", true},
{"german landline", "Tel: 030-12345678", true},
{"with spaces", "Phone: 0170 123 4567", true},
{"no phone", "This is just text", false},
{"US format", "Call 555-123-4567", false}, // Should not match German pattern
}
phonePattern := `(?:\+49|0)[\s.-]?\d{2,4}[\s.-]?\d{3,}[\s.-]?\d{2,}`
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := &PIIRule{
Name: "Phone",
RuleType: PIIRuleTypeRegex,
Pattern: phonePattern,
Severity: PIISeverityBlock,
}
detector := &PIIDetector{
compiledRules: make(map[string]*regexp.Regexp),
}
matches := detector.findMatches(tt.text, rule)
hasMatch := len(matches) > 0
if hasMatch != tt.hasPhone {
t.Errorf("Expected hasPhone=%v, got %v for text: %s", tt.hasPhone, hasMatch, tt.text)
}
})
}
}
func TestPIIDetector_IBANDetection(t *testing.T) {
tests := []struct {
name string
text string
hasIBAN bool
}{
{"valid IBAN", "IBAN: DE89 3704 0044 0532 0130 00", true},
{"compact IBAN", "DE89370400440532013000", true},
{"no IBAN", "Just a number: 12345678", false},
{"partial", "DE12 is not complete", false},
}
ibanPattern := `DE\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2}`
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := &PIIRule{
Name: "IBAN",
RuleType: PIIRuleTypeRegex,
Pattern: ibanPattern,
Severity: PIISeverityBlock,
}
detector := &PIIDetector{
compiledRules: make(map[string]*regexp.Regexp),
}
matches := detector.findMatches(tt.text, rule)
hasMatch := len(matches) > 0
if hasMatch != tt.hasIBAN {
t.Errorf("Expected hasIBAN=%v, got %v for text: %s", tt.hasIBAN, hasMatch, tt.text)
}
})
}
}
func TestPIIDetector_KeywordMatching(t *testing.T) {
tests := []struct {
name string
text string
keywords string
expected int
}{
{"single keyword", "The password is secret", "password", 1},
{"multiple keywords", "Password and secret", "password,secret", 2},
{"case insensitive", "PASSWORD and Secret", "password,secret", 2},
{"no match", "This is safe text", "password,secret", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := &PIIRule{
Name: "Keywords",
RuleType: PIIRuleTypeKeyword,
Pattern: tt.keywords,
Severity: PIISeverityWarn,
}
detector := &PIIDetector{
compiledRules: make(map[string]*regexp.Regexp),
}
matches := detector.findKeywordMatches(tt.text, rule)
if len(matches) != tt.expected {
t.Errorf("Expected %d matches, got %d for text: %s", tt.expected, len(matches), tt.text)
}
})
}
}
func TestPIIDetector_Redaction(t *testing.T) {
detector := &PIIDetector{
compiledRules: make(map[string]*regexp.Regexp),
}
tests := []struct {
name string
text string
matches []PIIMatch
expected string
}{
{
"single redaction",
"Email: test@example.com",
[]PIIMatch{{StartIndex: 7, EndIndex: 23, Severity: PIISeverityBlock}},
"Email: ****************",
},
{
"no matches",
"Plain text",
[]PIIMatch{},
"Plain text",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := detector.RedactText(tt.text, tt.matches)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestCompareSeverity(t *testing.T) {
tests := []struct {
a, b PIISeverity
expected int
}{
{PIISeverityBlock, PIISeverityWarn, 1},
{PIISeverityWarn, PIISeverityBlock, -1},
{PIISeverityBlock, PIISeverityBlock, 0},
{PIISeverityRedact, PIISeverityWarn, 1},
{PIISeverityRedact, PIISeverityBlock, -1},
}
for _, tt := range tests {
t.Run(string(tt.a)+"_vs_"+string(tt.b), func(t *testing.T) {
result := compareSeverity(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Expected %d, got %d for %s vs %s", tt.expected, result, tt.a, tt.b)
}
})
}
}
// =============================================================================
// ENFORCER TESTS
// =============================================================================
func TestExtractDomain(t *testing.T) {
tests := []struct {
name string
url string
expected string
hasError bool
}{
{"full URL", "https://www.example.com/path", "example.com", false},
{"with port", "http://example.com:8080/path", "example.com", false},
{"subdomain", "https://sub.domain.example.com", "sub.domain.example.com", false},
{"no scheme", "example.com/path", "example.com", false},
{"www prefix", "https://www.test.de", "test.de", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := extractDomain(tt.url)
if tt.hasError && err == nil {
t.Error("Expected error, got nil")
}
if !tt.hasError && err != nil {
t.Errorf("Expected no error, got %v", err)
}
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
// =============================================================================
// YAML LOADER TESTS
// =============================================================================
func TestParseYAML(t *testing.T) {
yamlData := `
federal:
name: "Test Federal"
sources:
- domain: "test.gov"
name: "Test Source"
license: "§5 UrhG"
trust_boost: 0.9
NI:
name: "Niedersachsen"
sources:
- domain: "ni.gov"
name: "NI Source"
license: "DL-DE-BY-2.0"
default_operations:
lookup:
allowed: true
requires_citation: true
training:
allowed: false
requires_citation: false
pii_rules:
- name: "Test Rule"
type: "regex"
pattern: "test.*pattern"
severity: "block"
`
config, err := ParseYAML([]byte(yamlData))
if err != nil {
t.Fatalf("Failed to parse YAML: %v", err)
}
// Test federal
if config.Federal.Name != "Test Federal" {
t.Errorf("Expected federal name 'Test Federal', got '%s'", config.Federal.Name)
}
if len(config.Federal.Sources) != 1 {
t.Errorf("Expected 1 federal source, got %d", len(config.Federal.Sources))
}
if config.Federal.Sources[0].Domain != "test.gov" {
t.Errorf("Expected domain 'test.gov', got '%s'", config.Federal.Sources[0].Domain)
}
if config.Federal.Sources[0].TrustBoost != 0.9 {
t.Errorf("Expected trust_boost 0.9, got %f", config.Federal.Sources[0].TrustBoost)
}
// Test Bundesland
if len(config.Bundeslaender) != 1 {
t.Errorf("Expected 1 Bundesland, got %d", len(config.Bundeslaender))
}
ni, ok := config.Bundeslaender["NI"]
if !ok {
t.Error("Expected NI in Bundeslaender")
}
if ni.Name != "Niedersachsen" {
t.Errorf("Expected name 'Niedersachsen', got '%s'", ni.Name)
}
// Test operations
if !config.DefaultOperations.Lookup.Allowed {
t.Error("Expected lookup to be allowed")
}
if config.DefaultOperations.Training.Allowed {
t.Error("Expected training to be NOT allowed")
}
// Test PII rules
if len(config.PIIRules) != 1 {
t.Errorf("Expected 1 PII rule, got %d", len(config.PIIRules))
}
if config.PIIRules[0].Name != "Test Rule" {
t.Errorf("Expected rule name 'Test Rule', got '%s'", config.PIIRules[0].Name)
}
}
// =============================================================================
// AUDIT TESTS
// =============================================================================
func TestMaskPII(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"short", "ab", "****"},
{"medium", "test@email.com", "te****om"},
{"long", "very-long-email@example.com", "ve****om"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := maskPII(tt.input)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
// =============================================================================
// DEFAULT PII RULES TEST
// =============================================================================
func TestDefaultPIIRules(t *testing.T) {
rules := DefaultPIIRules()
if len(rules) == 0 {
t.Error("Expected default PII rules, got none")
}
// Check that each rule has required fields
for _, rule := range rules {
if rule.Name == "" {
t.Error("Rule name should not be empty")
}
if rule.Type == "" {
t.Error("Rule type should not be empty")
}
if rule.Pattern == "" {
t.Error("Rule pattern should not be empty")
}
}
// Check for email rule
hasEmailRule := false
for _, rule := range rules {
if rule.Name == "Email Addresses" {
hasEmailRule = true
break
}
}
if !hasEmailRule {
t.Error("Expected email addresses rule in defaults")
}
}
// =============================================================================
// INTEGRATION TEST HELPERS
// =============================================================================
// TestFilteredURL tests the FilteredURL struct.
func TestFilteredURL(t *testing.T) {
fu := FilteredURL{
URL: "https://example.com",
IsAllowed: true,
RequiresCitation: true,
}
if fu.URL != "https://example.com" {
t.Error("URL not set correctly")
}
if !fu.IsAllowed {
t.Error("IsAllowed should be true")
}
if !fu.RequiresCitation {
t.Error("RequiresCitation should be true")
}
}