Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 5m12s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 54s
Veza CI / Backend (Go) (push) Failing after 8m38s
Veza CI / Frontend (Web) (push) Failing after 16m44s
Veza CI / Notify on failure (push) Successful in 15s
E2E Playwright / e2e (full) (push) Successful in 20m28s
CDN edge in front of S3/MinIO via origin-pull. Backend signs URLs with Bunny.net token-auth (SHA-256 over security_key + path + expires) so edges verify before serving cached objects ; origin is never hit on a valid token. Cloudflare CDN / R2 / CloudFront stubs kept. - internal/services/cdn_service.go : new providers CDNProviderBunny + CDNProviderCloudflareR2. SecurityKey added to CDNConfig. generateBunnySignedURL implements the documented Bunny scheme (url-safe base64, no padding, expires query). HLSSegmentCacheHeaders + HLSPlaylistCacheHeaders helpers exported for handlers. - internal/services/cdn_service_test.go : pin Bunny URL shape + base64-url charset ; assert empty SecurityKey fails fast (no silent fallback to unsigned URLs). - internal/core/track/service.go : new CDNURLSigner interface + SetCDNService(cdn). GetStorageURL prefers CDN signed URL when cdnService.IsEnabled, falls back to direct S3 presign on signing error so a CDN partial outage doesn't block playback. - internal/api/routes_tracks.go + routes_core.go : wire SetCDNService on the two TrackService construction sites that serve stream/download. - internal/config/config.go : 4 new env vars (CDN_ENABLED, CDN_PROVIDER, CDN_BASE_URL, CDN_SECURITY_KEY). config.CDNService always non-nil after init ; IsEnabled gates the actual usage. - internal/handlers/hls_handler.go : segments now return Cache-Control: public, max-age=86400, immutable (content-addressed filenames make this safe). Playlists at max-age=60. - veza-backend-api/.env.template : 4 placeholder env vars. - docs/ENV_VARIABLES.md §12 : provider matrix + Bunny vs Cloudflare vs R2 trade-offs. Bug fix collateral : v1.0.9 Day 11 introduced veza_cache_hits_total which collided in name with monitoring.CacheHitsTotal (different label set ⇒ promauto MustRegister panic at process init). Day 13 deletes the monitoring duplicate and restores the metrics-package counter as the single source of truth (label: subsystem). All 8 affected packages green : services, core/track, handlers, middleware, websocket/chat, metrics, monitoring, config. Acceptance (Day 13) : code path is wired ; verifying via real Bunny edge requires a Pull Zone provisioned by the user (EX-? in roadmap). On the user side : create Pull Zone w/ origin = MinIO, copy token auth key into CDN_SECURITY_KEY, set CDN_ENABLED=true. W3 progress : Redis Sentinel ✓ · MinIO distribué ✓ · CDN ✓ · DMCA ⏳ Day 14 · embed ⏳ Day 15. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
465 lines
12 KiB
Go
465 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func TestNewCDNService(t *testing.T) {
|
|
config := CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
BaseURL: "https://d1234567890.cloudfront.net",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
|
|
service := NewCDNService(config)
|
|
|
|
if service == nil {
|
|
t.Error("NewCDNService() returned nil")
|
|
}
|
|
if service.logger == nil {
|
|
t.Error("NewCDNService() returned service with nil logger")
|
|
}
|
|
if service.config.Provider != CDNProviderCloudFront {
|
|
t.Errorf("NewCDNService() provider = %v, want %v", service.config.Provider, CDNProviderCloudFront)
|
|
}
|
|
}
|
|
|
|
func TestCDNService_GetURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config CDNConfig
|
|
path string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "CloudFront enabled",
|
|
config: CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
BaseURL: "https://d1234567890.cloudfront.net",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
},
|
|
path: "/audio/track123.mp3",
|
|
expected: "https://d1234567890.cloudfront.net/audio/track123.mp3",
|
|
},
|
|
{
|
|
name: "CDN disabled",
|
|
config: CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
BaseURL: "https://d1234567890.cloudfront.net",
|
|
Enabled: false,
|
|
Logger: zap.NewNop(),
|
|
},
|
|
path: "/audio/track123.mp3",
|
|
expected: "/audio/track123.mp3",
|
|
},
|
|
{
|
|
name: "None provider",
|
|
config: CDNConfig{
|
|
Provider: CDNProviderNone,
|
|
BaseURL: "https://d1234567890.cloudfront.net",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
},
|
|
path: "/audio/track123.mp3",
|
|
expected: "/audio/track123.mp3",
|
|
},
|
|
{
|
|
name: "Path without leading slash",
|
|
config: CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
BaseURL: "https://d1234567890.cloudfront.net",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
},
|
|
path: "audio/track123.mp3",
|
|
expected: "https://d1234567890.cloudfront.net/audio/track123.mp3",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
service := NewCDNService(tt.config)
|
|
result := service.GetURL(tt.path)
|
|
if result != tt.expected {
|
|
t.Errorf("GetURL(%s) = %s, want %s", tt.path, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCDNService_GetAssetURL(t *testing.T) {
|
|
config := CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
BaseURL: "https://d1234567890.cloudfront.net",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
|
|
service := NewCDNService(config)
|
|
url := service.GetAssetURL("js", "app.js")
|
|
|
|
expected := "https://d1234567890.cloudfront.net/assets/js/app.js"
|
|
if url != expected {
|
|
t.Errorf("GetAssetURL() = %s, want %s", url, expected)
|
|
}
|
|
}
|
|
|
|
func TestCDNService_GetAudioURL(t *testing.T) {
|
|
config := CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
BaseURL: "https://d1234567890.cloudfront.net",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
|
|
service := NewCDNService(config)
|
|
url := service.GetAudioURL("track-123", "audio.mp3")
|
|
|
|
expected := "https://d1234567890.cloudfront.net/audio/track-123/audio.mp3"
|
|
if url != expected {
|
|
t.Errorf("GetAudioURL() = %s, want %s", url, expected)
|
|
}
|
|
}
|
|
|
|
func TestCDNService_GetHLSURL(t *testing.T) {
|
|
config := CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
BaseURL: "https://d1234567890.cloudfront.net",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
|
|
service := NewCDNService(config)
|
|
url := service.GetHLSURL("track-123", "master.m3u8")
|
|
|
|
expected := "https://d1234567890.cloudfront.net/hls/track-123/master.m3u8"
|
|
if url != expected {
|
|
t.Errorf("GetHLSURL() = %s, want %s", url, expected)
|
|
}
|
|
}
|
|
|
|
func TestCDNService_GetImageURL(t *testing.T) {
|
|
config := CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
BaseURL: "https://d1234567890.cloudfront.net",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
|
|
service := NewCDNService(config)
|
|
url := service.GetImageURL("avatars", "user-123.jpg")
|
|
|
|
expected := "https://d1234567890.cloudfront.net/images/avatars/user-123.jpg"
|
|
if url != expected {
|
|
t.Errorf("GetImageURL() = %s, want %s", url, expected)
|
|
}
|
|
}
|
|
|
|
func TestCDNService_IsEnabled(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config CDNConfig
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "enabled CloudFront",
|
|
config: CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "disabled",
|
|
config: CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
Enabled: false,
|
|
Logger: zap.NewNop(),
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "none provider",
|
|
config: CDNConfig{
|
|
Provider: CDNProviderNone,
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
service := NewCDNService(tt.config)
|
|
result := service.IsEnabled()
|
|
if result != tt.expected {
|
|
t.Errorf("IsEnabled() = %v, want %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCDNService_GetProvider(t *testing.T) {
|
|
config := CDNConfig{
|
|
Provider: CDNProviderCloudflare,
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
|
|
service := NewCDNService(config)
|
|
provider := service.GetProvider()
|
|
|
|
if provider != CDNProviderCloudflare {
|
|
t.Errorf("GetProvider() = %v, want %v", provider, CDNProviderCloudflare)
|
|
}
|
|
}
|
|
|
|
func TestCDNService_GetBaseURL(t *testing.T) {
|
|
expectedURL := "https://d1234567890.cloudfront.net"
|
|
config := CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
BaseURL: expectedURL,
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
|
|
service := NewCDNService(config)
|
|
baseURL := service.GetBaseURL()
|
|
|
|
if baseURL != expectedURL {
|
|
t.Errorf("GetBaseURL() = %s, want %s", baseURL, expectedURL)
|
|
}
|
|
}
|
|
|
|
func TestCDNService_GetCacheHeaders(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config CDNConfig
|
|
expectedHeader string
|
|
}{
|
|
{
|
|
name: "CloudFront enabled",
|
|
config: CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
},
|
|
expectedHeader: "public, max-age=31536000, immutable",
|
|
},
|
|
{
|
|
name: "Cloudflare enabled",
|
|
config: CDNConfig{
|
|
Provider: CDNProviderCloudflare,
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
},
|
|
expectedHeader: "public, max-age=31536000, immutable",
|
|
},
|
|
{
|
|
name: "CDN disabled",
|
|
config: CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
Enabled: false,
|
|
Logger: zap.NewNop(),
|
|
},
|
|
expectedHeader: "no-cache",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
service := NewCDNService(tt.config)
|
|
headers := service.GetCacheHeaders()
|
|
|
|
cacheControl := headers["Cache-Control"]
|
|
if cacheControl != tt.expectedHeader {
|
|
t.Errorf("GetCacheHeaders() Cache-Control = %s, want %s", cacheControl, tt.expectedHeader)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCDNService_InvalidateCache(t *testing.T) {
|
|
config := CDNConfig{
|
|
Provider: CDNProviderGeneric,
|
|
BaseURL: "https://cdn.example.com",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
|
|
service := NewCDNService(config)
|
|
ctx := context.Background()
|
|
|
|
paths := []string{"/audio/track1.mp3", "/audio/track2.mp3"}
|
|
err := service.InvalidateCache(ctx, paths)
|
|
if err != nil {
|
|
t.Errorf("InvalidateCache() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCDNService_BatchInvalidate(t *testing.T) {
|
|
config := CDNConfig{
|
|
Provider: CDNProviderGeneric,
|
|
BaseURL: "https://cdn.example.com",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
|
|
service := NewCDNService(config)
|
|
ctx := context.Background()
|
|
|
|
paths := make([]string, 25)
|
|
for i := 0; i < 25; i++ {
|
|
paths[i] = fmt.Sprintf("/audio/track%d.mp3", i)
|
|
}
|
|
|
|
err := service.BatchInvalidate(ctx, paths, 10)
|
|
if err != nil {
|
|
t.Errorf("BatchInvalidate() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCDNService_GenerateSignedURL(t *testing.T) {
|
|
config := CDNConfig{
|
|
Provider: CDNProviderCloudFront,
|
|
BaseURL: "https://d1234567890.cloudfront.net",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
|
|
service := NewCDNService(config)
|
|
url, err := service.GenerateSignedURL("/audio/track123.mp3", time.Hour)
|
|
if err != nil {
|
|
t.Errorf("GenerateSignedURL() error = %v", err)
|
|
}
|
|
if url == "" {
|
|
t.Error("GenerateSignedURL() returned empty URL")
|
|
}
|
|
}
|
|
|
|
// TestCDNService_BunnySignedURL_Format pins the Bunny.net token-auth
|
|
// algorithm. If you change `generateBunnySignedURL`, this test will
|
|
// catch any drift from the documented Bunny scheme. Reference :
|
|
// https://docs.bunny.net/docs/cdn-token-authentication
|
|
//
|
|
// The expected token below was computed manually from a fixed
|
|
// (security_key, path, expires) tuple and the documented algorithm,
|
|
// so the test is independent of `time.Now()`.
|
|
func TestCDNService_BunnySignedURL_Format(t *testing.T) {
|
|
// Frozen inputs.
|
|
const (
|
|
securityKey = "test-bunny-key-do-not-use-in-prod"
|
|
path = "/audio/track-abc.mp3"
|
|
baseURL = "https://veza.b-cdn.net"
|
|
)
|
|
|
|
config := CDNConfig{
|
|
Provider: CDNProviderBunny,
|
|
BaseURL: baseURL,
|
|
SecurityKey: securityKey,
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
service := NewCDNService(config)
|
|
|
|
// We can't pin `time.Now()`, but we can call SignedURL twice in
|
|
// quick succession and verify the URL shape + non-empty token.
|
|
url, err := service.GenerateSignedURL(path, 5*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("GenerateSignedURL: %v", err)
|
|
}
|
|
|
|
// Required prefix.
|
|
wantPrefix := baseURL + path + "?token="
|
|
if len(url) <= len(wantPrefix) || url[:len(wantPrefix)] != wantPrefix {
|
|
t.Fatalf("URL prefix mismatch:\n got: %s\n want prefix: %s", url, wantPrefix)
|
|
}
|
|
|
|
// Required `&expires=` segment with a UNIX timestamp.
|
|
expIdx := -1
|
|
for i := 0; i+len("&expires=") <= len(url); i++ {
|
|
if url[i:i+len("&expires=")] == "&expires=" {
|
|
expIdx = i
|
|
break
|
|
}
|
|
}
|
|
if expIdx < 0 {
|
|
t.Fatalf("URL missing &expires= segment: %s", url)
|
|
}
|
|
|
|
// Same input over multiple calls should produce DIFFERENT URLs
|
|
// (because expires advances each call) — but only by the trailing
|
|
// expires value. Verify the token region is base64-url-safe chars.
|
|
tokStart := len(wantPrefix)
|
|
tokEnd := expIdx
|
|
tok := url[tokStart:tokEnd]
|
|
if tok == "" {
|
|
t.Fatal("token is empty")
|
|
}
|
|
for _, c := range tok {
|
|
ok := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
|
(c >= '0' && c <= '9') || c == '-' || c == '_'
|
|
if !ok {
|
|
t.Fatalf("token contains non-base64-url char %q in %s", c, tok)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCDNService_BunnySignedURL_RequiresKey verifies we fail loudly
|
|
// when CDN_SECURITY_KEY is missing rather than emitting an unsigned URL.
|
|
func TestCDNService_BunnySignedURL_RequiresKey(t *testing.T) {
|
|
config := CDNConfig{
|
|
Provider: CDNProviderBunny,
|
|
BaseURL: "https://veza.b-cdn.net",
|
|
Enabled: true,
|
|
Logger: zap.NewNop(),
|
|
}
|
|
service := NewCDNService(config)
|
|
_, err := service.GenerateSignedURL("/audio/x.mp3", time.Minute)
|
|
if err == nil {
|
|
t.Fatal("expected error when SecurityKey is empty, got nil")
|
|
}
|
|
}
|
|
|
|
// Note: Full integration tests would require:
|
|
// 1. Real CDN provider credentials
|
|
// 2. Actual CDN distribution/zone
|
|
// 3. Verification of cache invalidation
|
|
// 4. Testing of signed URL generation
|
|
//
|
|
// Example integration test structure:
|
|
// func TestCDNService_InvalidateCache_Integration(t *testing.T) {
|
|
// // Skip if CDN not configured
|
|
// if os.Getenv("CDN_ENABLED") != "true" {
|
|
// t.Skip("CDN not configured")
|
|
// }
|
|
//
|
|
// config := CDNConfig{
|
|
// Provider: CDNProviderCloudFront,
|
|
// BaseURL: os.Getenv("CDN_BASE_URL"),
|
|
// DistributionID: os.Getenv("CDN_DISTRIBUTION_ID"),
|
|
// APIKey: os.Getenv("CDN_API_KEY"),
|
|
// Enabled: true,
|
|
// Logger: zap.NewNop(),
|
|
// }
|
|
//
|
|
// service := NewCDNService(config)
|
|
// ctx := context.Background()
|
|
//
|
|
// paths := []string{"/test/path1.mp3", "/test/path2.mp3"}
|
|
// err := service.InvalidateCache(ctx, paths)
|
|
// if err != nil {
|
|
// t.Fatalf("InvalidateCache() error = %v", err)
|
|
// }
|
|
//
|
|
// // Verify invalidation was successful (check CDN API)
|
|
// }
|