diff --git a/internal/camera/stream/builder.go b/internal/camera/stream/builder.go index e7e2bd2..c928f76 100644 --- a/internal/camera/stream/builder.go +++ b/internal/camera/stream/builder.go @@ -323,15 +323,17 @@ func (b *Builder) BuildURLsFromEntry(entry models.CameraEntry, ctx BuildContext) } case "rtsp", "rtsps": - // For RTSP: generate with and without credentials + // For RTSP: generate ONLY with credentials if provided, otherwise without if ctx.Username != "" && ctx.Password != "" { + // Credentials provided - generate ONLY URL with auth addURL(b.BuildURL(entry, ctx)) + } else { + // No credentials - generate ONLY URL without auth + ctxNoAuth := ctx + ctxNoAuth.Username = "" + ctxNoAuth.Password = "" + addURL(b.BuildURL(entry, ctxNoAuth)) } - // Without credentials (for open cameras) - ctxNoAuth := ctx - ctxNoAuth.Username = "" - ctxNoAuth.Password = "" - addURL(b.BuildURL(entry, ctxNoAuth)) case "http", "https": // For HTTP/HTTPS: ALWAYS generate 4 authentication variants diff --git a/internal/camera/stream/builder_dedup_test.go b/internal/camera/stream/builder_dedup_test.go index 78e483f..bab20f7 100644 --- a/internal/camera/stream/builder_dedup_test.go +++ b/internal/camera/stream/builder_dedup_test.go @@ -63,7 +63,7 @@ func TestCurrentDeduplicationProblems(t *testing.T) { description: "PROBLEM: Placeholder replacement + auth variants = duplicates", }, { - name: "RTSP with/without credentials", + name: "RTSP with credentials - now FIXED", entry: models.CameraEntry{ Type: "FFMPEG", Protocol: "rtsp", @@ -76,9 +76,9 @@ func TestCurrentDeduplicationProblems(t *testing.T) { Password: "12345", Port: 554, }, - expectedURLCount: 2, // С credentials и без + expectedURLCount: 1, // FIXED: только с credentials realUniqueCount: 1, // Это один поток - description: "PROBLEM: RTSP with and without credentials are both generated", + description: "FIXED: RTSP with credentials generates ONLY auth URL", }, { name: "RTSP without credentials - only one URL", @@ -123,6 +123,8 @@ func TestCurrentDeduplicationProblems(t *testing.T) { t.Logf("\n⚠️ PROBLEM: %d semantic duplicates generated", duplicateCount) t.Logf("These are different URL strings pointing to the SAME stream!") t.Logf("Waste: %d unnecessary tests", duplicateCount) + } else if len(urls) == tt.realUniqueCount && tt.expectedURLCount == tt.realUniqueCount { + t.Logf("\n✓ NO DUPLICATES: All URLs are unique (FIXED!)") } // Показать канонические URL diff --git a/internal/camera/stream/deduplication_real_test.go b/internal/camera/stream/deduplication_real_test.go new file mode 100644 index 0000000..822a179 --- /dev/null +++ b/internal/camera/stream/deduplication_real_test.go @@ -0,0 +1,420 @@ +package stream + +import ( + "testing" + + "github.com/eduard256/Strix/internal/models" +) + +// TestRealWorldDeduplication тестирует реальный сценарий: +// 5 одинаковых URL из 3 разных источников (ONVIF, Model patterns, Popular patterns) +func TestRealWorldDeduplication(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Channel: 1, + Port: 554, + } + + t.Log("\n========================================") + t.Log("REAL WORLD SCENARIO: Same stream from 3 sources") + t.Log("========================================\n") + + // === SOURCE 1: ONVIF Discovery === + t.Log("=== SOURCE 1: ONVIF Discovery ===") + onvifStreams := []models.DiscoveredStream{ + { + URL: "rtsp://192.168.1.100:554/Streaming/Channels/101", + Type: "ONVIF", + Protocol: "rtsp", + Port: 554, + Working: true, // ONVIF streams are pre-verified + }, + } + t.Logf("ONVIF discovered: %d URLs", len(onvifStreams)) + for i, s := range onvifStreams { + t.Logf(" [ONVIF-%d] %s", i+1, s.URL) + } + + // === SOURCE 2: Model-specific patterns (Hikvision) === + t.Log("\n=== SOURCE 2: Model-specific patterns (Hikvision DS-2CD2086) ===") + modelEntry := models.CameraEntry{ + Models: []string{"DS-2CD2086G2-I", "DS-2CD2042WD"}, + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/Streaming/Channels/101", + } + modelURLs := builder.BuildURLsFromEntry(modelEntry, ctx) + t.Logf("Model patterns generated: %d URLs", len(modelURLs)) + for i, url := range modelURLs { + t.Logf(" [MODEL-%d] %s", i+1, url) + } + + // === SOURCE 3: Popular patterns === + t.Log("\n=== SOURCE 3: Popular patterns (generic RTSP) ===") + popularEntry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/Streaming/Channels/101", + } + popularURLs := builder.BuildURLsFromEntry(popularEntry, ctx) + t.Logf("Popular patterns generated: %d URLs", len(popularURLs)) + for i, url := range popularURLs { + t.Logf(" [POPULAR-%d] %s", i+1, url) + } + + // === CURRENT DEDUPLICATION (как в scanner.go:235-395) === + t.Log("\n=== CURRENT DEDUPLICATION (string comparison) ===") + urlMap := make(map[string]bool) + var allStreams []models.DiscoveredStream + + // Add ONVIF streams + for _, stream := range onvifStreams { + if !urlMap[stream.URL] { + allStreams = append(allStreams, stream) + urlMap[stream.URL] = true + t.Logf("✓ Added: %s (from ONVIF)", stream.URL) + } else { + t.Logf("✗ Skipped: %s (duplicate from ONVIF)", stream.URL) + } + } + + // Add Model URLs + for _, url := range modelURLs { + if !urlMap[url] { + allStreams = append(allStreams, models.DiscoveredStream{ + URL: url, + Type: modelEntry.Type, + Protocol: modelEntry.Protocol, + Port: modelEntry.Port, + }) + urlMap[url] = true + t.Logf("✓ Added: %s (from Model)", url) + } else { + t.Logf("✗ Skipped: %s (duplicate from Model)", url) + } + } + + // Add Popular URLs + for _, url := range popularURLs { + if !urlMap[url] { + allStreams = append(allStreams, models.DiscoveredStream{ + URL: url, + Type: popularEntry.Type, + Protocol: popularEntry.Protocol, + Port: popularEntry.Port, + }) + urlMap[url] = true + t.Logf("✓ Added: %s (from Popular)", url) + } else { + t.Logf("✗ Skipped: %s (duplicate from Popular)", url) + } + } + + // === RESULTS === + t.Log("\n========================================") + t.Log("DEDUPLICATION RESULTS") + t.Log("========================================") + + totalGenerated := len(onvifStreams) + len(modelURLs) + len(popularURLs) + t.Logf("Total URLs generated: %d", totalGenerated) + t.Logf(" - From ONVIF: %d", len(onvifStreams)) + t.Logf(" - From Model: %d", len(modelURLs)) + t.Logf(" - From Popular: %d", len(popularURLs)) + t.Logf("\nURLs after deduplication: %d", len(allStreams)) + t.Logf("Duplicates removed: %d", totalGenerated-len(allStreams)) + + // List final URLs + t.Log("\nFinal URLs to test:") + for i, stream := range allStreams { + t.Logf(" [%d] %s (type: %s)", i+1, stream.URL, stream.Type) + } + + // === CANONICAL ANALYSIS (показывает реальные дубликаты) === + t.Log("\n========================================") + t.Log("CANONICAL ANALYSIS (semantic duplicates)") + t.Log("========================================") + + canonicalMap := make(map[string][]string) + for _, stream := range allStreams { + canonical := normalizeURLForComparison(stream.URL) + canonicalMap[canonical] = append(canonicalMap[canonical], stream.URL) + } + + realUnique := len(canonicalMap) + semanticDuplicates := len(allStreams) - realUnique + + t.Logf("Real unique streams: %d", realUnique) + t.Logf("Semantic duplicates: %d", semanticDuplicates) + + if semanticDuplicates > 0 { + t.Log("\n⚠️ PROBLEM: Multiple URLs point to the SAME stream:") + for canonical, variants := range canonicalMap { + if len(variants) > 1 { + t.Logf("\n Canonical: %s", canonical) + t.Logf(" Variants (%d):", len(variants)) + for _, v := range variants { + t.Logf(" - %s", v) + } + t.Logf(" ⚠️ This stream will be tested %d times!", len(variants)) + } + } + + t.Logf("\n⚠️ WASTE: %d unnecessary tests", semanticDuplicates) + t.Logf("Time waste: ~%d seconds (assuming 2s per test)", semanticDuplicates*2) + t.Logf("Bandwidth waste: ~%d KB (assuming 100KB per test)", semanticDuplicates*100) + } else { + t.Log("\n✓ No semantic duplicates found") + } + + // === ASSERTION === + if semanticDuplicates > 0 { + t.Errorf("DEDUPLICATION FAILED: %d semantic duplicates not removed", semanticDuplicates) + } +} + +// TestHTTPAuthVariantsDuplication проверяет дубликаты от HTTP auth вариантов +func TestHTTPAuthVariantsDuplication(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 80, + } + + t.Log("\n========================================") + t.Log("HTTP AUTHENTICATION VARIANTS TEST") + t.Log("========================================\n") + + // Один entry для HTTP + entry := models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.cgi", + } + + t.Log("Entry: http://192.168.1.100/snapshot.cgi") + t.Log("\nBuilder generates auth variants:") + + urls := builder.BuildURLsFromEntry(entry, ctx) + for i, url := range urls { + t.Logf(" [%d] %s", i+1, url) + } + + t.Logf("\nTotal URLs generated: %d", len(urls)) + + // Canonical analysis + canonicalMap := make(map[string][]string) + for _, url := range urls { + canonical := normalizeURLForComparison(url) + canonicalMap[canonical] = append(canonicalMap[canonical], url) + } + + t.Logf("Real unique endpoints: %d", len(canonicalMap)) + semanticDuplicates := len(urls) - len(canonicalMap) + t.Logf("Semantic duplicates: %d", semanticDuplicates) + + if semanticDuplicates > 0 { + t.Log("\n⚠️ PROBLEM: Multiple auth variants for the SAME endpoint:") + for canonical, variants := range canonicalMap { + if len(variants) > 1 { + t.Logf("\n Endpoint: %s", canonical) + t.Logf(" Auth variants (%d):", len(variants)) + for j, v := range variants { + t.Logf(" [%d] %s", j+1, v) + } + } + } + + t.Logf("\n⚠️ All %d variants will be tested, but only 1 will likely work", len(urls)) + t.Logf("Expected success rate: ~25%% (1 out of 4)") + t.Logf("Expected failures: ~%d", len(urls)-1) + } + + // Note: это НЕ ошибка - это feature для повышения шансов найти рабочий вариант auth + t.Log("\nNOTE: This is intentional - trying multiple auth methods increases success rate") + t.Log("But it does mean testing the same stream multiple times with different credentials") +} + +// TestFiveIdenticalURLsFromThreeSources - главный тест: ровно 5 одинаковых URL +func TestFiveIdenticalURLsFromThreeSources(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "password123", + Port: 554, + } + + t.Log("\n========================================") + t.Log("TEST: 5 IDENTICAL URLs from 3 SOURCES") + t.Log("========================================\n") + + // SOURCE 1: ONVIF - returns 1 URL without auth + onvifURL := "rtsp://192.168.1.100:554/live/ch0" + t.Log("SOURCE 1 - ONVIF Discovery:") + t.Logf(" Returns: %s", onvifURL) + + // SOURCE 2: Model patterns - generates 2 URLs (with/without auth) + modelEntry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/live/ch0", + } + modelURLs := builder.BuildURLsFromEntry(modelEntry, ctx) + t.Log("\nSOURCE 2 - Model Patterns (Hikvision):") + t.Logf(" Generates: %d URLs", len(modelURLs)) + for i, url := range modelURLs { + t.Logf(" [%d] %s", i+1, url) + } + + // SOURCE 3: Popular patterns - generates 2 URLs (with/without auth) + popularEntry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/live/ch0", + } + popularURLs := builder.BuildURLsFromEntry(popularEntry, ctx) + t.Log("\nSOURCE 3 - Popular Patterns:") + t.Logf(" Generates: %d URLs", len(popularURLs)) + for i, url := range popularURLs { + t.Logf(" [%d] %s", i+1, url) + } + + // Simulate current deduplication + urlMap := make(map[string]bool) + var combined []string + + // Add ONVIF + if !urlMap[onvifURL] { + combined = append(combined, onvifURL) + urlMap[onvifURL] = true + } + + // Add Model + for _, url := range modelURLs { + if !urlMap[url] { + combined = append(combined, url) + urlMap[url] = true + } + } + + // Add Popular + for _, url := range popularURLs { + if !urlMap[url] { + combined = append(combined, url) + urlMap[url] = true + } + } + + t.Log("\n========================================") + t.Log("RESULTS") + t.Log("========================================") + + totalGenerated := 1 + len(modelURLs) + len(popularURLs) + t.Logf("Total URLs from all sources: %d", totalGenerated) + t.Logf(" ONVIF: 1") + t.Logf(" Model: %d", len(modelURLs)) + t.Logf(" Popular: %d", len(popularURLs)) + + t.Logf("\nAfter string-based deduplication: %d URLs", len(combined)) + t.Logf("Removed by string comparison: %d", totalGenerated-len(combined)) + + t.Log("\nFinal URLs to test:") + for i, url := range combined { + t.Logf(" [%d] %s", i+1, url) + } + + // Canonical analysis + canonicalMap := make(map[string][]string) + for _, url := range combined { + canonical := normalizeURLForComparison(url) + canonicalMap[canonical] = append(canonicalMap[canonical], url) + } + + realUnique := len(canonicalMap) + semanticDuplicates := len(combined) - realUnique + + t.Log("\n========================================") + t.Log("SEMANTIC ANALYSIS") + t.Log("========================================") + t.Logf("Real unique streams: %d", realUnique) + t.Logf("Semantic duplicates: %d", semanticDuplicates) + + if semanticDuplicates > 0 { + t.Log("\n⚠️ CRITICAL ISSUE:") + t.Logf("The same stream will be tested %d times!", len(combined)) + t.Log("\nBreakdown:") + for canonical, variants := range canonicalMap { + t.Logf("\n Stream: %s", canonical) + t.Logf(" Will be tested %d times as:", len(variants)) + for i, v := range variants { + t.Logf(" [%d] %s", i+1, v) + } + } + + t.Log("\n⚠️ IMPACT:") + t.Logf(" - Wasted tests: %d", semanticDuplicates) + t.Logf(" - Wasted time: ~%d seconds", semanticDuplicates*2) + t.Logf(" - Efficiency: %.1f%% (should be 100%%)", + float64(realUnique)/float64(len(combined))*100) + + t.Errorf("\nDEDUPLICATION FAILED: %d duplicates not detected", semanticDuplicates) + } else { + t.Log("\n✓ SUCCESS: All duplicates properly detected") + } +} + +// normalizeURLForComparison убирает различия в auth для сравнения +func normalizeURLForComparison(rawURL string) string { + // Простая нормализация: убираем user:pass@ из URL + url := rawURL + + // Найти protocol:// + protocolEnd := 0 + for i := 0; i < len(url)-3; i++ { + if url[i:i+3] == "://" { + protocolEnd = i + 3 + break + } + } + + if protocolEnd == 0 { + return url + } + + protocol := url[:protocolEnd] + rest := url[protocolEnd:] + + // Убрать user:pass@ + atIndex := -1 + for i := 0; i < len(rest); i++ { + if rest[i] == '@' { + atIndex = i + break + } + if rest[i] == '/' { + break + } + } + + if atIndex >= 0 { + rest = rest[atIndex+1:] + } + + return protocol + rest +} diff --git a/internal/camera/stream/protocol_comparison_test.go b/internal/camera/stream/protocol_comparison_test.go new file mode 100644 index 0000000..036342b --- /dev/null +++ b/internal/camera/stream/protocol_comparison_test.go @@ -0,0 +1,351 @@ +package stream + +import ( + "strings" + "testing" + + "github.com/eduard256/Strix/internal/models" +) + +// TestProtocolAuthBehaviorComparison проверяет разницу в генерации auth вариантов +// между RTSP и HTTP протоколами +func TestProtocolAuthBehaviorComparison(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 0, // Will use default for protocol + } + + t.Log("\n" + strings.Repeat("=", 80)) + t.Log("PROTOCOL AUTH BEHAVIOR COMPARISON") + t.Log(strings.Repeat("=", 80)) + + // === RTSP === + t.Log("\n### RTSP Protocol ###") + rtspEntry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/live/ch0", + } + + rtspURLs := builder.BuildURLsFromEntry(rtspEntry, ctx) + + t.Logf("\nRTSP with credentials (user=%s, pass=%s):", "admin", "***") + t.Logf("Generated: %d URL(s)", len(rtspURLs)) + for i, url := range rtspURLs { + t.Logf(" [%d] %s", i+1, url) + } + + // Check RTSP behavior + if len(rtspURLs) != 1 { + t.Errorf("❌ RTSP: Expected 1 URL, got %d", len(rtspURLs)) + } + + hasRTSPAuth := false + hasRTSPNoAuth := false + for _, url := range rtspURLs { + if strings.Contains(url, "@") { + hasRTSPAuth = true + } else { + hasRTSPNoAuth = true + } + } + + if !hasRTSPAuth { + t.Error("❌ RTSP: Should have URL WITH auth") + } + if hasRTSPNoAuth { + t.Error("❌ RTSP: Should NOT have URL without auth when credentials provided") + } + + if len(rtspURLs) == 1 && hasRTSPAuth && !hasRTSPNoAuth { + t.Log("✅ RTSP: Correctly generates ONLY auth URL") + } + + // === HTTP === + t.Log("\n### HTTP Protocol ###") + httpEntry := models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.cgi", + } + + httpURLs := builder.BuildURLsFromEntry(httpEntry, ctx) + + t.Logf("\nHTTP with credentials (user=%s, pass=%s):", "admin", "***") + t.Logf("Generated: %d URL(s)", len(httpURLs)) + for i, url := range httpURLs { + t.Logf(" [%d] %s", i+1, url) + } + + // Check HTTP behavior + if len(httpURLs) != 4 { + t.Errorf("❌ HTTP: Expected 4 URLs, got %d", len(httpURLs)) + } + + // Analyze HTTP URLs + type authVariant struct { + name string + found bool + url string + } + + variants := []authVariant{ + {name: "No auth", found: false}, + {name: "Basic auth only", found: false}, + {name: "Query params only", found: false}, + {name: "Basic auth + Query params", found: false}, + } + + for _, url := range httpURLs { + hasBasicAuth := strings.Contains(url, "@") + hasQueryParams := strings.Contains(url, "?") + + if !hasBasicAuth && !hasQueryParams { + variants[0].found = true + variants[0].url = url + } else if hasBasicAuth && !hasQueryParams { + variants[1].found = true + variants[1].url = url + } else if !hasBasicAuth && hasQueryParams { + variants[2].found = true + variants[2].url = url + } else if hasBasicAuth && hasQueryParams { + variants[3].found = true + variants[3].url = url + } + } + + t.Log("\nHTTP Auth variants breakdown:") + allFound := true + for i, v := range variants { + if v.found { + t.Logf(" ✅ [%d] %s: %s", i+1, v.name, v.url) + } else { + t.Errorf(" ❌ [%d] %s: MISSING", i+1, v.name) + allFound = false + } + } + + if allFound { + t.Log("\n✅ HTTP: Correctly generates ALL 4 auth variants") + } else { + t.Error("\n❌ HTTP: Missing some auth variants") + } + + // === COMPARISON SUMMARY === + t.Log("\n" + strings.Repeat("=", 80)) + t.Log("SUMMARY") + t.Log(strings.Repeat("=", 80)) + t.Log("\nRTSP behavior:") + t.Log(" • With credentials → 1 URL (WITH auth only)") + t.Log(" • Without credentials → 1 URL (NO auth only)") + t.Log(" • Rationale: RTSP auth is binary (works or doesn't)") + t.Log("") + t.Log("HTTP behavior:") + t.Log(" • With credentials → 4 URLs:") + t.Log(" 1. No auth (try public access)") + t.Log(" 2. Basic auth only (user:pass@host)") + t.Log(" 3. Query params only (?user=X&pwd=Y)") + t.Log(" 4. Both methods combined") + t.Log(" • Rationale: Different cameras support different auth methods") + t.Log(strings.Repeat("=", 80)) +} + +// TestRTSPNoAuthWhenNoCredentials проверяет что RTSP без credentials НЕ генерирует auth URL +func TestRTSPNoAuthWhenNoCredentials(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + rtspEntry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/live/main", + } + + // Without credentials + ctxNoAuth := BuildContext{ + IP: "192.168.1.100", + Username: "", + Password: "", + Port: 554, + } + + urls := builder.BuildURLsFromEntry(rtspEntry, ctxNoAuth) + + t.Log("\n=== RTSP WITHOUT credentials ===") + t.Logf("Generated: %d URL(s)", len(urls)) + for i, url := range urls { + t.Logf(" [%d] %s", i+1, url) + } + + if len(urls) != 1 { + t.Errorf("Expected 1 URL, got %d", len(urls)) + } + + if len(urls) > 0 { + if strings.Contains(urls[0], "@") { + t.Error("❌ Should NOT have auth when no credentials provided") + } else { + t.Log("✅ Correctly generates URL without auth") + } + } +} + +// TestHTTPNoAuthWhenNoCredentials проверяет что HTTP без credentials генерирует ТОЛЬКО 1 URL +func TestHTTPNoAuthWhenNoCredentials(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + httpEntry := models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.jpg", + } + + // Without credentials + ctxNoAuth := BuildContext{ + IP: "192.168.1.100", + Username: "", + Password: "", + Port: 80, + } + + urls := builder.BuildURLsFromEntry(httpEntry, ctxNoAuth) + + t.Log("\n=== HTTP WITHOUT credentials ===") + t.Logf("Generated: %d URL(s)", len(urls)) + for i, url := range urls { + t.Logf(" [%d] %s", i+1, url) + } + + if len(urls) != 1 { + t.Errorf("Expected 1 URL, got %d", len(urls)) + } + + if len(urls) > 0 { + if strings.Contains(urls[0], "@") || strings.Contains(urls[0], "?") { + t.Error("❌ Should NOT have auth when no credentials provided") + } else { + t.Log("✅ Correctly generates URL without auth") + } + } +} + +// TestCompleteProtocolMatrix проверяет полную матрицу протоколов и credentials +func TestCompleteProtocolMatrix(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + type testCase struct { + protocol string + port int + url string + withCreds bool + expectedURLs int + description string + } + + tests := []testCase{ + // RTSP + { + protocol: "rtsp", + port: 554, + url: "/live/ch0", + withCreds: true, + expectedURLs: 1, + description: "RTSP with credentials", + }, + { + protocol: "rtsp", + port: 554, + url: "/live/ch0", + withCreds: false, + expectedURLs: 1, + description: "RTSP without credentials", + }, + // HTTP + { + protocol: "http", + port: 80, + url: "snapshot.cgi", + withCreds: true, + expectedURLs: 4, + description: "HTTP with credentials", + }, + { + protocol: "http", + port: 80, + url: "snapshot.cgi", + withCreds: false, + expectedURLs: 1, + description: "HTTP without credentials", + }, + // HTTPS + { + protocol: "https", + port: 443, + url: "snapshot.cgi", + withCreds: true, + expectedURLs: 4, + description: "HTTPS with credentials", + }, + { + protocol: "https", + port: 443, + url: "snapshot.cgi", + withCreds: false, + expectedURLs: 1, + description: "HTTPS without credentials", + }, + } + + t.Log("\n" + strings.Repeat("=", 80)) + t.Log("COMPLETE PROTOCOL MATRIX") + t.Log(strings.Repeat("=", 80)) + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + entry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: tc.protocol, + Port: tc.port, + URL: tc.url, + } + + ctx := BuildContext{ + IP: "192.168.1.100", + Port: tc.port, + } + + if tc.withCreds { + ctx.Username = "admin" + ctx.Password = "12345" + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + + t.Logf("Protocol: %s, Creds: %v → Generated: %d URL(s)", + tc.protocol, tc.withCreds, len(urls)) + + if len(urls) != tc.expectedURLs { + t.Errorf("❌ Expected %d URLs, got %d", tc.expectedURLs, len(urls)) + for i, url := range urls { + t.Logf(" [%d] %s", i+1, url) + } + } else { + t.Logf("✅ Correct: %d URL(s)", len(urls)) + } + }) + } + + t.Log(strings.Repeat("=", 80)) +} diff --git a/internal/camera/stream/rtsp_auth_logic_test.go b/internal/camera/stream/rtsp_auth_logic_test.go new file mode 100644 index 0000000..4406b0b --- /dev/null +++ b/internal/camera/stream/rtsp_auth_logic_test.go @@ -0,0 +1,449 @@ +package stream + +import ( + "testing" + + "github.com/eduard256/Strix/internal/models" +) + +// TestRTSPAuthLogic проверяет логику генерации RTSP URL с авторизацией +func TestRTSPAuthLogic(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/live/ch0", + } + + tests := []struct { + name string + ctx BuildContext + expectedURLCount int + shouldHaveNoAuth bool + shouldHaveAuth bool + description string + }{ + { + name: "RTSP with credentials - should generate ONLY with auth", + ctx: BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 554, + }, + expectedURLCount: 1, + shouldHaveNoAuth: false, + shouldHaveAuth: true, + description: "When credentials provided, generate ONLY URL with auth", + }, + { + name: "RTSP without credentials - should generate ONLY without auth", + ctx: BuildContext{ + IP: "192.168.1.100", + Username: "", + Password: "", + Port: 554, + }, + expectedURLCount: 1, + shouldHaveNoAuth: true, + shouldHaveAuth: false, + description: "When NO credentials provided, generate ONLY URL without auth", + }, + { + name: "RTSP with only username (no password) - should generate without auth", + ctx: BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "", + Port: 554, + }, + expectedURLCount: 1, + shouldHaveNoAuth: true, + shouldHaveAuth: false, + description: "Username without password = no credentials", + }, + { + name: "RTSP with only password (no username) - should generate without auth", + ctx: BuildContext{ + IP: "192.168.1.100", + Username: "", + Password: "12345", + Port: 554, + }, + expectedURLCount: 1, + shouldHaveNoAuth: true, + shouldHaveAuth: false, + description: "Password without username = no credentials", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + urls := builder.BuildURLsFromEntry(entry, tt.ctx) + + t.Logf("\n=== %s ===", tt.description) + t.Logf("Context: IP=%s, User=%s, Pass=%s", + tt.ctx.IP, + maskString(tt.ctx.Username), + maskString(tt.ctx.Password)) + t.Logf("Generated URLs: %d", len(urls)) + + for i, url := range urls { + t.Logf(" [%d] %s", i+1, url) + } + + // Check count + if len(urls) != tt.expectedURLCount { + t.Errorf("FAILED: Expected %d URLs, got %d", tt.expectedURLCount, len(urls)) + } + + // Check for auth presence + hasNoAuth := false + hasAuth := false + + for _, url := range urls { + if containsAuth(url) { + hasAuth = true + } else { + hasNoAuth = true + } + } + + if tt.shouldHaveNoAuth && !hasNoAuth { + t.Errorf("FAILED: Expected URL without auth, but none found") + } + if !tt.shouldHaveNoAuth && hasNoAuth { + t.Errorf("FAILED: Expected NO URL without auth, but found one") + } + if tt.shouldHaveAuth && !hasAuth { + t.Errorf("FAILED: Expected URL with auth, but none found") + } + if !tt.shouldHaveAuth && hasAuth { + t.Errorf("FAILED: Expected NO URL with auth, but found one") + } + + t.Logf("✓ Test passed") + }) + } +} + +// TestHTTPAuthLogic проверяет что HTTP НЕ изменился (все 4 варианта) +func TestHTTPAuthLogic(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.cgi", + } + + t.Log("\n=== HTTP should generate ALL 4 auth variants (unchanged behavior) ===") + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 80, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + + t.Logf("Generated URLs: %d", len(urls)) + for i, url := range urls { + t.Logf(" [%d] %s", i+1, url) + } + + expectedCount := 4 + if len(urls) != expectedCount { + t.Errorf("FAILED: Expected %d URLs for HTTP, got %d", expectedCount, len(urls)) + t.Errorf("HTTP auth variant generation should NOT be changed!") + } else { + t.Log("✓ HTTP still generates 4 auth variants (correct)") + } + + // Verify we have different auth methods + hasNoAuth := false + hasBasicAuth := false + hasQueryAuth := false + hasBothAuth := false + + for _, url := range urls { + hasAuth := containsAuth(url) + hasQuery := containsString(url, "?") + + if !hasAuth && !hasQuery { + hasNoAuth = true + } else if hasAuth && !hasQuery { + hasBasicAuth = true + } else if !hasAuth && hasQuery { + hasQueryAuth = true + } else if hasAuth && hasQuery { + hasBothAuth = true + } + } + + if !hasNoAuth || !hasBasicAuth || !hasQueryAuth || !hasBothAuth { + t.Error("FAILED: HTTP should have all 4 auth variants:") + t.Logf(" No auth: %v", hasNoAuth) + t.Logf(" Basic auth: %v", hasBasicAuth) + t.Logf(" Query auth: %v", hasQueryAuth) + t.Logf(" Both: %v", hasBothAuth) + } else { + t.Log("✓ All 4 HTTP auth variants present (correct)") + } +} + +// TestHTTPSAuthLogic проверяет что HTTPS работает как HTTP +func TestHTTPSAuthLogic(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "JPEG", + Protocol: "https", + Port: 443, + URL: "snapshot.cgi", + } + + t.Log("\n=== HTTPS should generate ALL 4 auth variants (same as HTTP) ===") + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 443, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + + t.Logf("Generated URLs: %d", len(urls)) + for i, url := range urls { + t.Logf(" [%d] %s", i+1, url) + } + + expectedCount := 4 + if len(urls) != expectedCount { + t.Errorf("FAILED: Expected %d URLs for HTTPS, got %d", expectedCount, len(urls)) + } else { + t.Log("✓ HTTPS generates 4 auth variants (correct)") + } +} + +// TestBUBBLEProtocolUnchanged проверяет что BUBBLE протокол не изменился +func TestBUBBLEProtocolUnchanged(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "BUBBLE", + Protocol: "bubble", + Port: 34567, + URL: "/{channel}?stream=0", + } + + t.Log("\n=== BUBBLE protocol should remain unchanged ===") + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 34567, + Channel: 1, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + + t.Logf("Generated URLs: %d", len(urls)) + for i, url := range urls { + t.Logf(" [%d] %s", i+1, url) + } + + if len(urls) < 1 { + t.Error("FAILED: BUBBLE should generate at least 1 URL") + } else { + t.Log("✓ BUBBLE protocol works") + } +} + +// TestRTSPDeduplicationAcrossSources проверяет дедупликацию между источниками +func TestRTSPDeduplicationAcrossSources(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 554, + } + + entry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/live/ch0", + } + + t.Log("\n=== RTSP Deduplication: Each source generates ONLY auth URL ===") + + // Source 1: Model patterns + modelURLs := builder.BuildURLsFromEntry(entry, ctx) + t.Logf("Model patterns: %d URLs", len(modelURLs)) + for i, url := range modelURLs { + t.Logf(" [%d] %s", i+1, url) + } + + // Source 2: Popular patterns + popularURLs := builder.BuildURLsFromEntry(entry, ctx) + t.Logf("Popular patterns: %d URLs", len(popularURLs)) + for i, url := range popularURLs { + t.Logf(" [%d] %s", i+1, url) + } + + // Source 3: ONVIF (manual simulation - without auth) + onvifURL := "rtsp://192.168.1.100:554/live/ch0" + t.Logf("ONVIF: 1 URL") + t.Logf(" [1] %s", onvifURL) + + // Current deduplication + urlMap := make(map[string]bool) + var combined []string + + for _, url := range modelURLs { + if !urlMap[url] { + combined = append(combined, url) + urlMap[url] = true + } + } + + for _, url := range popularURLs { + if !urlMap[url] { + combined = append(combined, url) + urlMap[url] = true + } + } + + if !urlMap[onvifURL] { + combined = append(combined, onvifURL) + urlMap[onvifURL] = true + } + + t.Logf("\nAfter deduplication: %d URLs", len(combined)) + for i, url := range combined { + t.Logf(" [%d] %s", i+1, url) + } + + // Verify: should have exactly 2 URLs + // 1. From Model/Popular (with auth): rtsp://admin:12345@192.168.1.100/live/ch0 + // 2. From ONVIF (without auth, with port): rtsp://192.168.1.100:554/live/ch0 + expectedCount := 2 + if len(combined) != expectedCount { + t.Errorf("FAILED: Expected %d unique URLs, got %d", expectedCount, len(combined)) + t.Log("Expected:") + t.Log(" 1. rtsp://admin:12345@192.168.1.100/live/ch0 (from Model/Popular)") + t.Log(" 2. rtsp://192.168.1.100:554/live/ch0 (from ONVIF)") + } else { + t.Log("✓ Deduplication works correctly") + t.Log(" Model/Popular URLs are identical → deduplicated to 1") + t.Log(" ONVIF URL is different (has :554 port) → kept as separate") + t.Log(" Total: 2 unique URLs (correct!)") + } +} + +// TestRTSPWithoutCredentialsSingleURL проверяет что без credentials генерируется 1 URL +func TestRTSPWithoutCredentialsSingleURL(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/live/main", + } + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "", + Password: "", + Port: 554, + } + + t.Log("\n=== RTSP without credentials should generate SINGLE URL ===") + + urls := builder.BuildURLsFromEntry(entry, ctx) + + t.Logf("Generated URLs: %d", len(urls)) + for i, url := range urls { + t.Logf(" [%d] %s", i+1, url) + } + + if len(urls) != 1 { + t.Errorf("FAILED: Expected 1 URL without credentials, got %d", len(urls)) + } + + if len(urls) > 0 && containsAuth(urls[0]) { + t.Error("FAILED: URL should NOT contain auth when no credentials provided") + } + + t.Log("✓ Single URL without auth generated (correct)") +} + +// Helper functions + +func containsAuth(url string) bool { + // Check for user:pass@ pattern + for i := 0; i < len(url)-3; i++ { + if url[i:i+3] == "://" { + // Found protocol, check for @ + for j := i + 3; j < len(url); j++ { + if url[j] == '@' { + return true + } + if url[j] == '/' { + break + } + } + break + } + } + return false +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && findSubstring(s, substr) +} + +func findSubstring(s, substr string) bool { + if len(substr) == 0 { + return true + } + if len(s) < len(substr) { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + match := true + for j := 0; j < len(substr); j++ { + if s[i+j] != substr[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} + +func maskString(s string) string { + if s == "" { + return "(empty)" + } + return "***" +}