diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 03659f2..559d755 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -43,7 +43,17 @@ jobs: run: cd backend/chat && GOTOOLCHAIN=local go vet ./... - name: Run Tests - run: echo "Tests will be added in future CI steps" + run: cd backend/chat && GOTOOLCHAIN=local go test ./... -v -cover -coverprofile=coverage.out -race + + - name: Check Coverage + run: | + cd backend/chat + COVERAGE=$(GOTOOLCHAIN=local go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + echo "Coverage: $COVERAGE%" + if [ "$(echo "$COVERAGE < 50" | bc)" -eq 1 ]; then + echo "Coverage $COVERAGE% below 50% threshold" + exit 1 + fi docker: needs: test diff --git a/.gitignore b/.gitignore index b19512c..1e4e74a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ .env.local .env.*.local +# Test coverage +coverage +coverage.out + # Logs logs *.log diff --git a/backend/chat/go.mod b/backend/chat/go.mod index 5268bc1..0f86410 100644 --- a/backend/chat/go.mod +++ b/backend/chat/go.mod @@ -3,24 +3,33 @@ module chat go 1.25.0 require ( + github.com/glebarez/sqlite v1.11.0 github.com/gorilla/websocket v1.5.3 github.com/redis/go-redis/v9 v9.7.0 gorm.io/driver/postgres v1.5.11 - gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.24.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/backend/chat/go.sum b/backend/chat/go.sum index 93e3f3d..69d70f3 100644 --- a/backend/chat/go.sum +++ b/backend/chat/go.sum @@ -9,6 +9,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -23,12 +33,15 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -38,6 +51,9 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -46,7 +62,13 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= -gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= -gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/backend/chat/src/auth/auth_test.go b/backend/chat/src/auth/auth_test.go new file mode 100644 index 0000000..8631b00 --- /dev/null +++ b/backend/chat/src/auth/auth_test.go @@ -0,0 +1,248 @@ +package auth + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestGenerateToken(t *testing.T) { + token, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken() error = %v", err) + } + if len(token) != 64 { + t.Errorf("GenerateToken() length = %d, want 64", len(token)) + } + + // Uniqueness check + tokens := make(map[string]bool) + for i := 0; i < 1000; i++ { + tok, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken() iteration %d error = %v", i, err) + } + if tokens[tok] { + t.Errorf("GenerateToken() produced duplicate token at iteration %d", i) + } + tokens[tok] = true + } +} + +func TestSessionStore_Validate(t *testing.T) { + store := NewSessionStore() + + // Empty token + if store.Validate("") { + t.Error("Validate(\"\") = true, want false") + } + + // Missing token + if store.Validate("nonexistent") { + t.Error("Validate(\"nonexistent\") = true, want false") + } + + // Valid token + token, _ := GenerateToken() + store.Add(token, time.Now().Add(1*time.Hour)) + if !store.Validate(token) { + t.Error("Validate(valid) = false, want true") + } + + // Expired token + expiredToken, _ := GenerateToken() + store.Add(expiredToken, time.Now().Add(-1*time.Hour)) + if store.Validate(expiredToken) { + t.Error("Validate(expired) = true, want false") + } + + // Expired token should be cleaned up + if store.Validate(expiredToken) { + t.Error("Validate(expired) second call = true, want false (should be cleaned)") + } +} + +func TestSessionStore_AddRemove(t *testing.T) { + store := NewSessionStore() + token, _ := GenerateToken() + + store.Add(token, time.Now().Add(1*time.Hour)) + if !store.Validate(token) { + t.Error("Validate after Add = false, want true") + } + + store.Remove(token) + if store.Validate(token) { + t.Error("Validate after Remove = true, want false") + } +} + +func TestSessionStore_CountActive(t *testing.T) { + store := NewSessionStore() + + if count := store.CountActive(); count != 0 { + t.Errorf("CountActive() = %d, want 0", count) + } + + t1, _ := GenerateToken() + t2, _ := GenerateToken() + t3, _ := GenerateToken() + + store.Add(t1, time.Now().Add(1*time.Hour)) + store.Add(t2, time.Now().Add(1*time.Hour)) + store.Add(t3, time.Now().Add(-1*time.Hour)) // expired + + if count := store.CountActive(); count != 2 { + t.Errorf("CountActive() = %d, want 2 (expired should be cleaned)", count) + } +} + +func TestCookieSecure(t *testing.T) { + tests := []struct { + name string + forwardedProto string + tls bool + expected bool + }{ + {"https forwarded", "https", false, true}, + {"http forwarded", "http", false, false}, + {"empty forwarded, no TLS", "", false, false}, + {"empty forwarded, TLS", "", true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + if tt.forwardedProto != "" { + req.Header.Set("X-Forwarded-Proto", tt.forwardedProto) + } + if tt.tls { + req.TLS = &tls.ConnectionState{} + } + if got := CookieSecure(req); got != tt.expected { + t.Errorf("CookieSecure() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestSetCookie(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Forwarded-Proto", "https") + w := httptest.NewRecorder() + + expiresAt := time.Now().Add(1 * time.Hour) + SetCookie(w, req, "test_cookie", "abc123", expiresAt) + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("expected 1 cookie, got %d", len(cookies)) + } + c := cookies[0] + if c.Name != "test_cookie" { + t.Errorf("cookie name = %q, want test_cookie", c.Name) + } + if c.Value != "abc123" { + t.Errorf("cookie value = %q, want abc123", c.Value) + } + if !c.HttpOnly { + t.Error("cookie HttpOnly = false, want true") + } + if c.SameSite != http.SameSiteStrictMode { + t.Errorf("cookie SameSite = %v, want Strict", c.SameSite) + } + if !c.Secure { + t.Error("cookie Secure = false, want true (X-Forwarded-Proto: https)") + } + if c.Path != "/" { + t.Errorf("cookie Path = %q, want /", c.Path) + } +} + +func TestClearCookie(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + ClearCookie(w, req, "test_cookie") + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("expected 1 cookie, got %d", len(cookies)) + } + c := cookies[0] + if c.Name != "test_cookie" { + t.Errorf("cookie name = %q, want test_cookie", c.Name) + } + if c.Value != "" { + t.Errorf("cookie value = %q, want empty", c.Value) + } + if c.MaxAge != -1 { + t.Errorf("cookie MaxAge = %d, want -1", c.MaxAge) + } +} + +func TestGetTokenFromCookie(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "token123"}) + + token, ok := GetTokenFromCookie(req, "session") + if !ok { + t.Error("GetTokenFromCookie() ok = false, want true") + } + if token != "token123" { + t.Errorf("GetTokenFromCookie() = %q, want token123", token) + } + + // Missing cookie + _, ok = GetTokenFromCookie(req, "nonexistent") + if ok { + t.Error("GetTokenFromCookie() for missing cookie ok = true, want false") + } + + // Empty value + req2 := httptest.NewRequest("GET", "/", nil) + req2.AddCookie(&http.Cookie{Name: "session", Value: ""}) + _, ok = GetTokenFromCookie(req2, "session") + if ok { + t.Error("GetTokenFromCookie() for empty value ok = true, want false") + } +} + +func TestRequireAdmin(t *testing.T) { + store := NewSessionStore() + token, _ := GenerateToken() + store.Add(token, time.Now().Add(1*time.Hour)) + + handler := RequireAdmin(store, "session")(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + // Valid session + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: token}) + w := httptest.NewRecorder() + handler(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + // No cookie + req2 := httptest.NewRequest("GET", "/", nil) + w2 := httptest.NewRecorder() + handler(w2, req2) + if w2.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w2.Code) + } + + // Invalid token + req3 := httptest.NewRequest("GET", "/", nil) + req3.AddCookie(&http.Cookie{Name: "session", Value: "invalid"}) + w3 := httptest.NewRecorder() + handler(w3, req3) + if w3.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w3.Code) + } +} \ No newline at end of file diff --git a/backend/chat/src/config/config_test.go b/backend/chat/src/config/config_test.go new file mode 100644 index 0000000..fbb04d7 --- /dev/null +++ b/backend/chat/src/config/config_test.go @@ -0,0 +1,267 @@ +package config + +import ( + "os" + "testing" + "time" +) + +func TestPlaylistPath(t *testing.T) { + tests := []struct { + name string + env string + expected string + }{ + {"default", "", "/app/playlist/playlist.txt"}, + {"custom", "/custom/playlist.txt", "/custom/playlist.txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + os.Setenv("PLAYLIST_PATH", tt.env) + defer os.Unsetenv("PLAYLIST_PATH") + } else { + os.Unsetenv("PLAYLIST_PATH") + } + if got := PlaylistPath(); got != tt.expected { + t.Errorf("PlaylistPath() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestMediaDir(t *testing.T) { + tests := []struct { + name string + env string + expected string + }{ + {"default", "", "/app/media"}, + {"custom", "/data/media", "/data/media"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + os.Setenv("MEDIA_DIR", tt.env) + defer os.Unsetenv("MEDIA_DIR") + } else { + os.Unsetenv("MEDIA_DIR") + } + if got := MediaDir(); got != tt.expected { + t.Errorf("MediaDir() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestAdminUsername(t *testing.T) { + tests := []struct { + name string + env string + expected string + }{ + {"default", "", "admin"}, + {"custom", "superadmin", "superadmin"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + os.Setenv("ADMIN_USERNAME", tt.env) + defer os.Unsetenv("ADMIN_USERNAME") + } else { + os.Unsetenv("ADMIN_USERNAME") + } + if got := AdminUsername(); got != tt.expected { + t.Errorf("AdminUsername() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestAdminPassword(t *testing.T) { + tests := []struct { + name string + env string + expected string + }{ + {"default", "", "admin"}, + {"custom", "secret123", "secret123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + os.Setenv("ADMIN_PASSWORD", tt.env) + defer os.Unsetenv("ADMIN_PASSWORD") + } else { + os.Unsetenv("ADMIN_PASSWORD") + } + if got := AdminPassword(); got != tt.expected { + t.Errorf("AdminPassword() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestAdminSessionTTL(t *testing.T) { + tests := []struct { + name string + env string + expected time.Duration + }{ + {"default", "", 12 * time.Hour}, + {"custom", "24", 24 * time.Hour}, + {"zero", "0", 12 * time.Hour}, + {"negative", "-5", 12 * time.Hour}, + {"invalid", "abc", 12 * time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + os.Setenv("ADMIN_SESSION_TTL_HOURS", tt.env) + defer os.Unsetenv("ADMIN_SESSION_TTL_HOURS") + } else { + os.Unsetenv("ADMIN_SESSION_TTL_HOURS") + } + if got := AdminSessionTTL(); got != tt.expected { + t.Errorf("AdminSessionTTL() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestRateLimitMax(t *testing.T) { + tests := []struct { + name string + env string + expected int + }{ + {"default", "", 10}, + {"custom", "20", 20}, + {"zero", "0", 10}, + {"negative", "-5", 10}, + {"invalid", "abc", 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + os.Setenv("CHAT_RATE_LIMIT_MAX", tt.env) + defer os.Unsetenv("CHAT_RATE_LIMIT_MAX") + } else { + os.Unsetenv("CHAT_RATE_LIMIT_MAX") + } + if got := RateLimitMax(); got != tt.expected { + t.Errorf("RateLimitMax() = %d, want %d", got, tt.expected) + } + }) + } +} + +func TestRateLimitWindow(t *testing.T) { + tests := []struct { + name string + env string + expected time.Duration + }{ + {"default", "", 10 * time.Second}, + {"custom", "30", 30 * time.Second}, + {"zero", "0", 10 * time.Second}, + {"negative", "-5", 10 * time.Second}, + {"invalid", "abc", 10 * time.Second}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + os.Setenv("CHAT_RATE_LIMIT_WINDOW_S", tt.env) + defer os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S") + } else { + os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S") + } + if got := RateLimitWindow(); got != tt.expected { + t.Errorf("RateLimitWindow() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestChatPort(t *testing.T) { + tests := []struct { + name string + env string + expected string + }{ + {"default", "", "3000"}, + {"custom", "8080", "8080"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + os.Setenv("CHAT_PORT", tt.env) + defer os.Unsetenv("CHAT_PORT") + } else { + os.Unsetenv("CHAT_PORT") + } + if got := ChatPort(); got != tt.expected { + t.Errorf("ChatPort() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestRedisAddress(t *testing.T) { + tests := []struct { + name string + env string + expected string + }{ + {"default", "", "redis:6379"}, + {"custom", "redis.example.com:6380", "redis.example.com:6380"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + os.Setenv("REDIS_URL", tt.env) + defer os.Unsetenv("REDIS_URL") + } else { + os.Unsetenv("REDIS_URL") + } + if got := RedisAddress(); got != tt.expected { + t.Errorf("RedisAddress() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestDatabaseURL(t *testing.T) { + tests := []struct { + name string + env string + expected string + }{ + {"default", "", ""}, + {"sqlite", "sqlite://test.db", "sqlite://test.db"}, + {"postgres", "postgres://user:pass@localhost/db", "postgres://user:pass@localhost/db"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + os.Setenv("DATABASE_URL", tt.env) + defer os.Unsetenv("DATABASE_URL") + } else { + os.Unsetenv("DATABASE_URL") + } + if got := DatabaseURL(); got != tt.expected { + t.Errorf("DatabaseURL() = %q, want %q", got, tt.expected) + } + }) + } +} \ No newline at end of file diff --git a/backend/chat/src/db/db.go b/backend/chat/src/db/db.go index 221b47b..e9a3ed9 100644 --- a/backend/chat/src/db/db.go +++ b/backend/chat/src/db/db.go @@ -6,8 +6,8 @@ import ( "strings" "time" + "github.com/glebarez/sqlite" "gorm.io/driver/postgres" - "gorm.io/driver/sqlite" "gorm.io/gorm" ) diff --git a/backend/chat/src/db/db_test.go b/backend/chat/src/db/db_test.go new file mode 100644 index 0000000..f82ba04 --- /dev/null +++ b/backend/chat/src/db/db_test.go @@ -0,0 +1,179 @@ +package db + +import ( + "testing" + "time" +) + +func TestInitDB_SQLite(t *testing.T) { + client, driver, err := InitDB("sqlite://:memory:") + if err != nil { + t.Fatalf("InitDB() error = %v", err) + } + if driver != "sqlite" { + t.Errorf("InitDB() driver = %q, want sqlite", driver) + } + if client == nil { + t.Fatal("InitDB() client = nil") + } + + // Verify tables exist by running AutoMigrate (idempotent) + err = client.DB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}) + if err != nil { + t.Errorf("AutoMigrate error = %v", err) + } + + // Verify CRUD on Message + msg := Message{ + Room: "test", + Username: "user1", + Text: "hello", + Timestamp: time.Now(), + Type: "public", + } + if err := client.DB.Create(&msg).Error; err != nil { + t.Fatalf("Create message error = %v", err) + } + if msg.ID == 0 { + t.Error("Create message did not set ID") + } + + var count int64 + client.DB.Model(&Message{}).Count(&count) + if count != 1 { + t.Errorf("Message count = %d, want 1", count) + } + + // Verify read + var read Message + if err := client.DB.First(&read, msg.ID).Error; err != nil { + t.Fatalf("First message error = %v", err) + } + if read.Text != "hello" { + t.Errorf("read.Text = %q, want hello", read.Text) + } + + // Verify delete + client.DB.Delete(&Message{}, msg.ID) + client.DB.Model(&Message{}).Count(&count) + if count != 0 { + t.Errorf("Message count after delete = %d, want 0", count) + } +} + +func TestInitDB_ModerationLog(t *testing.T) { + client, _, err := InitDB("sqlite://:memory:") + if err != nil { + t.Fatalf("InitDB() error = %v", err) + } + + log := ModerationLog{ + Action: "mute", + Target: "user1", + Moderator: "admin", + Reason: "spam", + Timestamp: time.Now(), + } + if err := client.DB.Create(&log).Error; err != nil { + t.Fatalf("Create moderation log error = %v", err) + } + if log.ID == 0 { + t.Error("Create moderation log did not set ID") + } + + var count int64 + client.DB.Model(&ModerationLog{}).Count(&count) + if count != 1 { + t.Errorf("ModerationLog count = %d, want 1", count) + } +} + +func TestInitDB_Media(t *testing.T) { + client, _, err := InitDB("sqlite://:memory:") + if err != nil { + t.Fatalf("InitDB() error = %v", err) + } + + media := Media{ + Filename: "test.mp4", + OriginalName: "test.mp4", + Path: "/media/test.mp4", + SizeBytes: 1024, + DurationSeconds: 60, + Status: "ready", + Title: "Test Video", + Tags: "tag1,tag2", + } + if err := client.DB.Create(&media).Error; err != nil { + t.Fatalf("Create media error = %v", err) + } + if media.ID == 0 { + t.Error("Create media did not set ID") + } + + // Verify update + client.DB.Model(&media).Update("status", "needs_normalization") + var updated Media + client.DB.First(&updated, media.ID) + if updated.Status != "needs_normalization" { + t.Errorf("updated.Status = %q, want needs_normalization", updated.Status) + } +} + +func TestInitDB_ScheduleOverride(t *testing.T) { + client, _, err := InitDB("sqlite://:memory:") + if err != nil { + t.Fatalf("InitDB() error = %v", err) + } + + now := time.Now() + ov := ScheduleOverride{ + MediaID: 1, + StartTime: now, + EndTime: now.Add(1 * time.Hour), + Reason: "special event", + } + if err := client.DB.Create(&ov).Error; err != nil { + t.Fatalf("Create schedule override error = %v", err) + } + if ov.ID == 0 { + t.Error("Create schedule override did not set ID") + } + + // Conflict detection + var conflict int64 + client.DB.Model(&ScheduleOverride{}). + Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime). + Count(&conflict) + if conflict != 1 { + t.Errorf("conflict count = %d, want 1", conflict) + } +} + +func TestInitDB_EmptyURL(t *testing.T) { + // Empty URL should default to SQLite chat.db + // This creates a file, clean up after + client, driver, err := InitDB("") + if err != nil { + t.Fatalf("InitDB(\"\") error = %v", err) + } + if driver != "sqlite" { + t.Errorf("InitDB(\"\") driver = %q, want sqlite", driver) + } + if client == nil { + t.Fatal("InitDB(\"\") client = nil") + } +} + +func TestInitDB_SQLitePath(t *testing.T) { + client, driver, err := InitDB("sqlite://:memory:?cache=shared") + if err != nil { + t.Fatalf("InitDB() error = %v", err) + } + if driver != "sqlite" { + t.Errorf("InitDB() driver = %q, want sqlite", driver) + } + if client == nil { + t.Fatal("InitDB() client = nil") + } +} \ No newline at end of file diff --git a/backend/chat/src/handlers/handlers_test.go b/backend/chat/src/handlers/handlers_test.go new file mode 100644 index 0000000..9772002 --- /dev/null +++ b/backend/chat/src/handlers/handlers_test.go @@ -0,0 +1,947 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "chat/src/auth" + "chat/src/config" + "chat/src/db" + "chat/src/ws" +) + +// mockRedis implements db.RedisClient for handler tests. +type mockRedis struct { + mu sync.Mutex + store map[string]string +} + +func newMockRedis() *mockRedis { + return &mockRedis{store: make(map[string]string)} +} + +func (m *mockRedis) Ping(ctx context.Context) error { return nil } +func (m *mockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + m.mu.Lock() + defer m.mu.Unlock() + m.store[key] = value.(string) + return nil +} +func (m *mockRedis) Get(ctx context.Context, key string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.store[key], nil +} +func (m *mockRedis) Del(ctx context.Context, keys ...string) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, k := range keys { + delete(m.store, k) + } + return nil +} +func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.store[key] = "1" + return 1, nil +} +func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil } +func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error { + return nil +} +func (m *mockRedis) Exists(ctx context.Context, keys ...string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, k := range keys { + if _, ok := m.store[k]; ok { + return 1, nil + } + } + return 0, nil +} +func (m *mockRedis) Publish(ctx context.Context, channel string, message interface{}) error { + return nil +} +func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub { + return nil +} +func (m *mockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var keys []string + for k := range m.store { + if strings.Contains(k, strings.TrimSuffix(match, "*")) { + keys = append(keys, k) + } + } + return keys, 0, nil +} +func (m *mockRedis) Info(ctx context.Context, sections ...string) (string, error) { + return "connected_clients:1\nused_memory_human:1M\n", nil +} +func (m *mockRedis) Close() error { return nil } + +func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) { + t.Helper() + gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open test DB: %v", err) + } + gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{}) + mr := newMockRedis() + sessionStore := auth.NewSessionStore() + hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername()) + + deps := &Dependencies{ + DB: gormDB, + Redis: mr, + Hub: hub, + SessionStore: sessionStore, + DBDriver: "sqlite", + } + return deps, gormDB, sessionStore +} + +// adminCookie creates a valid admin session and returns the cookie header. +func adminCookie(t *testing.T, store *auth.SessionStore) string { + t.Helper() + token, _ := auth.GenerateToken() + store.Add(token, time.Now().Add(1*time.Hour)) + return config.AdminSessionCookieName + "=" + token +} + +func TestHandleAdminLogin_Success(t *testing.T) { + deps, _, _ := setupTestDeps(t) + handler := HandleAdminLogin(deps) + + // Ensure env vars are set for credential check + os.Setenv("ADMIN_USERNAME", "admin") + os.Setenv("ADMIN_PASSWORD", "admin") + defer os.Unsetenv("ADMIN_USERNAME") + defer os.Unsetenv("ADMIN_PASSWORD") + + body := `{"username":"admin","password":"admin"}` + req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var resp AdminSessionResponse + json.NewDecoder(w.Body).Decode(&resp) + if !resp.Authenticated { + t.Error("Authenticated = false, want true") + } + + // Check cookie set + cookies := w.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == config.AdminSessionCookieName { + found = true + break + } + } + if !found { + t.Error("session cookie not set") + } +} + +func TestHandleAdminLogin_InvalidCreds(t *testing.T) { + deps, _, _ := setupTestDeps(t) + handler := HandleAdminLogin(deps) + + body := `{"username":"admin","password":"wrong"}` + req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestHandleAdminLogin_InvalidJSON(t *testing.T) { + deps, _, _ := setupTestDeps(t) + handler := HandleAdminLogin(deps) + + req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader("not json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestHandleAdminLogin_WrongMethod(t *testing.T) { + deps, _, _ := setupTestDeps(t) + handler := HandleAdminLogin(deps) + + req := httptest.NewRequest("GET", "/api/admin/login", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", w.Code) + } +} + +func TestHandleAdminLogout(t *testing.T) { + deps, _, store := setupTestDeps(t) + handler := HandleAdminLogout(deps) + + cookie := adminCookie(t, store) + req := httptest.NewRequest("POST", "/api/admin/logout", nil) + req.Header.Set("Cookie", cookie) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + // Cookie should be cleared + cookies := w.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == config.AdminSessionCookieName && c.MaxAge == -1 { + found = true + break + } + } + if !found { + t.Error("session cookie not cleared") + } +} + +func TestHandleAdminSession_Authenticated(t *testing.T) { + deps, _, store := setupTestDeps(t) + handler := HandleAdminSession(deps) + + cookie := adminCookie(t, store) + req := httptest.NewRequest("GET", "/api/admin/session", nil) + req.Header.Set("Cookie", cookie) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var resp AdminSessionResponse + json.NewDecoder(w.Body).Decode(&resp) + if !resp.Authenticated { + t.Error("Authenticated = false, want true") + } +} + +func TestHandleAdminSession_Unauthenticated(t *testing.T) { + deps, _, _ := setupTestDeps(t) + handler := HandleAdminSession(deps) + + req := httptest.NewRequest("GET", "/api/admin/session", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestHandleAdminOverview(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + // Add some test data + gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"}) + gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()}) + + handler := HandleAdminOverview(deps) + req := httptest.NewRequest("GET", "/api/admin/overview", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var overview AdminOverview + json.NewDecoder(w.Body).Decode(&overview) + if overview.StoredMessages != 1 { + t.Errorf("StoredMessages = %d, want 1", overview.StoredMessages) + } + if overview.ModerationActions != 1 { + t.Errorf("ModerationActions = %d, want 1", overview.ModerationActions) + } +} + +func TestHandleAdminPlaylist_Get(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + // Create temp playlist + tmpDir := t.TempDir() + playlistPath := filepath.Join(tmpDir, "playlist.txt") + os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644) + os.Setenv("PLAYLIST_PATH", playlistPath) + defer os.Unsetenv("PLAYLIST_PATH") + + handler := HandleAdminPlaylist(deps) + req := httptest.NewRequest("GET", "/api/admin/playlist", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var payload PlaylistPayload + json.NewDecoder(w.Body).Decode(&payload) + if !strings.Contains(payload.Content, "test.mp4") { + t.Errorf("Content = %q, want test.mp4", payload.Content) + } +} + +func TestHandleAdminPlaylist_Put(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + tmpDir := t.TempDir() + playlistPath := filepath.Join(tmpDir, "playlist.txt") + os.Setenv("PLAYLIST_PATH", playlistPath) + defer os.Unsetenv("PLAYLIST_PATH") + + handler := HandleAdminPlaylist(deps) + body := `{"content":"file /videos/new.mp4"}` + req := httptest.NewRequest("PUT", "/api/admin/playlist", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + // Verify file written + data, _ := os.ReadFile(playlistPath) + if !strings.Contains(string(data), "new.mp4") { + t.Errorf("file content = %q, want new.mp4", string(data)) + } +} + +func TestHandleAdminModerationLogs(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u1", Moderator: "admin", Reason: "spam", Timestamp: time.Now()}) + + handler := HandleAdminModerationLogs(deps) + req := httptest.NewRequest("GET", "/api/admin/moderation/logs", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var resp ModerationLogsResponse + json.NewDecoder(w.Body).Decode(&resp) + if len(resp.Logs) != 1 { + t.Errorf("len(Logs) = %d, want 1", len(resp.Logs)) + } +} + +func TestHandleAdminModerationAction(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + handler := HandleAdminModerationAction(deps) + body := `{"action":"mute","target":"baduser","moderator":"admin","reason":"spam","room":"main"}` + req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var count int64 + gormDB.Model(&db.ModerationLog{}).Count(&count) + if count != 1 { + t.Errorf("moderation log count = %d, want 1", count) + } +} + +func TestHandleAdminModerationAction_MissingFields(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleAdminModerationAction(deps) + body := `{"action":"mute"}` + req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestHandleAdminAnalytics(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"}) + gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"}) + + handler := HandleAdminAnalytics(deps) + req := httptest.NewRequest("GET", "/api/admin/analytics", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var analytics AdminAnalytics + json.NewDecoder(w.Body).Decode(&analytics) + if analytics.TotalMessages != 2 { + t.Errorf("TotalMessages = %d, want 2", analytics.TotalMessages) + } +} + +func TestHandleAdminSystemSummary(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleAdminSystemSummary(deps) + req := httptest.NewRequest("GET", "/api/admin/system", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var summary AdminSystemSummary + json.NewDecoder(w.Body).Decode(&summary) + if summary.DatabaseDriver != "sqlite" { + t.Errorf("DatabaseDriver = %q, want sqlite", summary.DatabaseDriver) + } +} + +func TestHandleAdminConfig_Get(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleAdminConfig(deps) + req := httptest.NewRequest("GET", "/api/admin/config", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestHandleAdminConfig_Put(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleAdminConfig(deps) + body := `{"rateLimitMax":20}` + req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestHandleAdminRestart(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleAdminRestart(deps) + req := httptest.NewRequest("POST", "/api/admin/restart?service=chat", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestHandleAdminUsers_Get(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleAdminUsers(deps) + req := httptest.NewRequest("GET", "/api/admin/users", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestHandleAdminUsers_Post(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleAdminUsers(deps) + body := `{"action":"mute","username":"baduser","duration":10}` + req := httptest.NewRequest("POST", "/api/admin/users", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestHandleAdminSchedule(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + tmpDir := t.TempDir() + playlistPath := filepath.Join(tmpDir, "playlist.txt") + os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644) + os.Setenv("PLAYLIST_PATH", playlistPath) + defer os.Unsetenv("PLAYLIST_PATH") + + gormDB.Create(&db.Media{ + Filename: "test.mp4", + OriginalName: "test.mp4", + Path: "/videos/test.mp4", + SizeBytes: 1024, + DurationSeconds: 60, + Status: "ready", + Title: "Test Video", + }) + + handler := HandleAdminSchedule(deps) + req := httptest.NewRequest("GET", "/api/admin/schedule", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var resp AdminScheduleResponse + json.NewDecoder(w.Body).Decode(&resp) + if resp.MatchedEntries != 1 { + t.Errorf("MatchedEntries = %d, want 1", resp.MatchedEntries) + } +} + +func TestHandleAdminScheduleOverrides_CRUD(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + // Create + handler := HandleAdminScheduleOverrides(deps) + now := time.Now() + body, _ := json.Marshal(db.ScheduleOverride{ + MediaID: 1, + StartTime: now, + EndTime: now.Add(1 * time.Hour), + Reason: "test", + }) + req := httptest.NewRequest("POST", "/api/admin/schedule/overrides", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("POST status = %d, want 201", w.Code) + } + + // List + req2 := httptest.NewRequest("GET", "/api/admin/schedule/overrides", nil) + w2 := httptest.NewRecorder() + handler(w2, req2) + + if w2.Code != http.StatusOK { + t.Errorf("GET status = %d, want 200", w2.Code) + } + + // Delete + var overrides []db.ScheduleOverride + gormDB.Find(&overrides) + if len(overrides) > 0 { + req3 := httptest.NewRequest("DELETE", "/api/admin/schedule/overrides?id=1", nil) + w3 := httptest.NewRecorder() + handler(w3, req3) + if w3.Code != http.StatusNoContent { + t.Errorf("DELETE status = %d, want 204", w3.Code) + } + } +} + +func TestHandleAdminChatLog(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}) + + handler := HandleAdminChatLog(deps) + req := httptest.NewRequest("GET", "/api/admin/chat-log", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var resp ChatLogListResponse + json.NewDecoder(w.Body).Decode(&resp) + if resp.Total != 1 { + t.Errorf("Total = %d, want 1", resp.Total) + } +} + +func TestHandleAdminChatLog_FilterByRoom(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}) + gormDB.Create(&db.Message{Room: "lobby", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"}) + + handler := HandleAdminChatLog(deps) + req := httptest.NewRequest("GET", "/api/admin/chat-log?room=main", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var resp ChatLogListResponse + json.NewDecoder(w.Body).Decode(&resp) + if resp.Total != 1 { + t.Errorf("Total = %d, want 1", resp.Total) + } +} + +func TestHandleAdminChatLogByID_Get(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"} + gormDB.Create(&msg) + + handler := HandleAdminChatLogByID(deps) + req := httptest.NewRequest("GET", "/api/admin/chat-log/1", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestHandleAdminChatLogByID_Delete(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"} + gormDB.Create(&msg) + + handler := HandleAdminChatLogByID(deps) + req := httptest.NewRequest("DELETE", "/api/admin/chat-log/1", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", w.Code) + } + + var count int64 + gormDB.Model(&db.Message{}).Count(&count) + if count != 0 { + t.Errorf("message count after delete = %d, want 0", count) + } +} + +func TestHandleAdminChatLogBulk(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "m1", Timestamp: time.Now(), Type: "public"}) + gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "m2", Timestamp: time.Now(), Type: "public"}) + + handler := HandleAdminChatLogBulk(deps) + body := `{"ids":[1,2]}` + req := httptest.NewRequest("DELETE", "/api/admin/chat-log/bulk", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var count int64 + gormDB.Model(&db.Message{}).Count(&count) + if count != 0 { + t.Errorf("message count after bulk delete = %d, want 0", count) + } +} + +func TestHandleAdminChatLogExport(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}) + + handler := HandleAdminChatLogExport(deps) + req := httptest.NewRequest("GET", "/api/admin/chat-log/export", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + contentType := w.Header().Get("Content-Type") + if !strings.Contains(contentType, "text/csv") { + t.Errorf("Content-Type = %q, want text/csv", contentType) + } + + body := w.Body.String() + if !strings.Contains(body, "hello") { + t.Errorf("CSV body missing 'hello': %s", body) + } +} + +func TestHandlePublicSchedule(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + tmpDir := t.TempDir() + playlistPath := filepath.Join(tmpDir, "playlist.txt") + os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644) + os.Setenv("PLAYLIST_PATH", playlistPath) + defer os.Unsetenv("PLAYLIST_PATH") + + handler := HandlePublicSchedule(deps) + req := httptest.NewRequest("GET", "/api/schedule", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestHandleAdminMedia_Get(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + gormDB.Create(&db.Media{ + Filename: "test.mp4", + OriginalName: "test.mp4", + Path: "/media/test.mp4", + SizeBytes: 1024, + DurationSeconds: 60, + Status: "ready", + Title: "Test", + }) + + handler := HandleAdminMedia(deps) + req := httptest.NewRequest("GET", "/api/admin/media", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var resp AdminMediaResponse + json.NewDecoder(w.Body).Decode(&resp) + if len(resp.Files) != 1 { + t.Errorf("len(Files) = %d, want 1", len(resp.Files)) + } +} + +func TestHandleAdminMediaByID_Get(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + gormDB.Create(&db.Media{ + Filename: "test.mp4", + OriginalName: "test.mp4", + Path: "/media/test.mp4", + SizeBytes: 1024, + DurationSeconds: 60, + Status: "ready", + Title: "Test", + }) + + handler := HandleAdminMediaByID(deps) + req := httptest.NewRequest("GET", "/api/admin/media/1", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestHandleAdminMediaByID_NotFound(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleAdminMediaByID(deps) + req := httptest.NewRequest("GET", "/api/admin/media/999", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestHandleAdminMediaByID_Put(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + gormDB.Create(&db.Media{ + Filename: "test.mp4", + OriginalName: "test.mp4", + Path: "/media/test.mp4", + SizeBytes: 1024, + DurationSeconds: 60, + Status: "ready", + Title: "Old Title", + }) + + handler := HandleAdminMediaByID(deps) + body := `{"title":"New Title","status":"needs_normalization","tags":"tag1"}` + req := httptest.NewRequest("PUT", "/api/admin/media/1", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var updated db.Media + gormDB.First(&updated, 1) + if updated.Title != "New Title" { + t.Errorf("Title = %q, want New Title", updated.Title) + } +} + +func TestHandleAdminMediaByID_Delete(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + // Create a real temp file for the media record + tmpDir := t.TempDir() + os.Setenv("MEDIA_DIR", tmpDir) + defer os.Unsetenv("MEDIA_DIR") + + mediaPath := filepath.Join(tmpDir, "test.mp4") + os.WriteFile(mediaPath, []byte("fake video"), 0644) + + gormDB.Create(&db.Media{ + Filename: "test.mp4", + OriginalName: "test.mp4", + Path: mediaPath, + SizeBytes: 10, + DurationSeconds: 1, + Status: "ready", + Title: "Test", + }) + + handler := HandleAdminMediaByID(deps) + req := httptest.NewRequest("DELETE", "/api/admin/media/1", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var count int64 + gormDB.Model(&db.Media{}).Count(&count) + if count != 0 { + t.Errorf("media count after delete = %d, want 0", count) + } +} + +func TestHandleAdminMediaUploads_Init(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleAdminMediaUploads(deps) + body := `{"fileName":"video.mp4","fileSize":1024,"mimeType":"video/mp4","chunkSize":524288,"totalChunks":1}` + req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + var status AdminMediaUploadStatusResponse + json.NewDecoder(w.Body).Decode(&status) + if status.FileName != "video.mp4" { + t.Errorf("FileName = %q, want video.mp4", status.FileName) + } + if status.UploadID == "" { + t.Error("UploadID is empty") + } +} + +func TestHandleAdminMediaUploads_InvalidFile(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleAdminMediaUploads(deps) + body := `{"fileName":"bad.exe","fileSize":1024,"mimeType":"application/x-msdownload","chunkSize":524288,"totalChunks":1}` + req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} \ No newline at end of file diff --git a/backend/chat/src/media/media_test.go b/backend/chat/src/media/media_test.go new file mode 100644 index 0000000..a101bd9 --- /dev/null +++ b/backend/chat/src/media/media_test.go @@ -0,0 +1,204 @@ +package media + +import ( + "path/filepath" + "testing" +) + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + seconds int + expected string + }{ + {"zero", 0, "00:00:00"}, + {"one second", 1, "00:00:01"}, + {"one minute", 60, "00:01:00"}, + {"one hour", 3600, "01:00:00"}, + {"mixed", 3661, "01:01:01"}, + {"large", 86399, "23:59:59"}, + {"negative", -5, "00:00:00"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FormatDuration(tt.seconds); got != tt.expected { + t.Errorf("FormatDuration(%d) = %q, want %q", tt.seconds, got, tt.expected) + } + }) + } +} + +func TestIsAllowedExtension(t *testing.T) { + tests := []struct { + name string + expected bool + }{ + {"video.mp4", true}, + {"video.mov", true}, + {"video.mkv", true}, + {"video.webm", true}, + {"video.m4v", true}, + {"video.ts", true}, + {"video.MP4", true}, + {"video.avi", false}, + {"video.flv", false}, + {"video.wmv", false}, + {"video", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsAllowedExtension(tt.name); got != tt.expected { + t.Errorf("IsAllowedExtension(%q) = %v, want %v", tt.name, got, tt.expected) + } + }) + } +} + +func TestIsAllowedMime(t *testing.T) { + tests := []struct { + mime string + expected bool + }{ + {"video/mp4", true}, + {"video/quicktime", true}, + {"video/x-matroska", true}, + {"video/webm", true}, + {"application/octet-stream", true}, + {"", true}, + {"image/jpeg", false}, + {"audio/mpeg", false}, + {"text/plain", false}, + } + + for _, tt := range tests { + t.Run(tt.mime, func(t *testing.T) { + if got := IsAllowedMime(tt.mime); got != tt.expected { + t.Errorf("IsAllowedMime(%q) = %v, want %v", tt.mime, got, tt.expected) + } + }) + } +} + +func TestValidateUploadID(t *testing.T) { + tests := []struct { + id string + expected bool + }{ + {"abcdef0123456789", true}, // 16 hex chars + {"abcdef0123456789abcdef0123456789", true}, // 32 hex chars + {"abc", false}, // too short + {"abcdef012345678", false}, // 15 chars (too short) + {"abcdef0123456789g", false}, // non-hex char + {"ABCDEF0123456789", false}, // uppercase (not allowed) + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + if got := ValidateUploadID(tt.id); got != tt.expected { + t.Errorf("ValidateUploadID(%q) = %v, want %v", tt.id, got, tt.expected) + } + }) + } +} + +func TestParsePlaylist(t *testing.T) { + tests := []struct { + name string + content string + expected int + first string + }{ + { + name: "simple", + content: "/videos/video1.mp4\n/videos/video2.mp4\n", + expected: 2, + first: "/videos/video1.mp4", + }, + { + name: "with file prefix", + content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n", + expected: 2, + first: "/videos/video1.mp4", + }, + { + name: "with comments and blanks", + content: "# comment\n\n/videos/video1.mp4\n\n# another comment\n/videos/video2.mp4\n", + expected: 2, + first: "/videos/video1.mp4", + }, + { + name: "empty", + content: "", + expected: 0, + }, + { + name: "only comments", + content: "# comment\n# another\n", + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entries := ParsePlaylist(tt.content) + if len(entries) != tt.expected { + t.Errorf("ParsePlaylist() len = %d, want %d", len(entries), tt.expected) + } + if tt.expected > 0 && entries[0].Path != tt.first { + t.Errorf("ParsePlaylist()[0].Path = %q, want %q", entries[0].Path, tt.first) + } + // Check indices + for i, e := range entries { + if e.Index != i+1 { + t.Errorf("ParsePlaylist()[%d].Index = %d, want %d", i, e.Index, i+1) + } + } + }) + } +} + +func TestGetMediaIDFromPath(t *testing.T) { + tests := []struct { + path string + prefix string + expected uint + expectErr bool + }{ + {"/api/admin/media/123", "/api/admin/media/", 123, false}, + {"/api/admin/media/1", "/api/admin/media/", 1, false}, + {"/api/admin/media/", "/api/admin/media/", 0, true}, + {"/api/admin/media/abc", "/api/admin/media/", 0, true}, + {"/api/admin/media/0", "/api/admin/media/", 0, true}, + {"/other/path", "/api/admin/media/", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + id, err := GetMediaIDFromPath(tt.path, tt.prefix) + if tt.expectErr && err == nil { + t.Errorf("GetMediaIDFromPath(%q) expected error, got nil", tt.path) + } + if !tt.expectErr && err != nil { + t.Errorf("GetMediaIDFromPath(%q) error = %v", tt.path, err) + } + if id != tt.expected { + t.Errorf("GetMediaIDFromPath(%q) = %d, want %d", tt.path, id, tt.expected) + } + }) + } +} + +func TestUniqueTargetPath(t *testing.T) { + // Test that the function returns a path with the mediaDir prefix + // and the correct filename. We can't easily test collision behavior + // without filesystem setup, but we can verify the basic path. + expected := filepath.Join("/tmp/media", "video.mp4") + path := UniqueTargetPath("/tmp/media", "video.mp4") + if path != expected { + t.Errorf("UniqueTargetPath() = %q, want %q", path, expected) + } +} \ No newline at end of file diff --git a/backend/chat/src/metrics/metrics_test.go b/backend/chat/src/metrics/metrics_test.go new file mode 100644 index 0000000..a2ae379 --- /dev/null +++ b/backend/chat/src/metrics/metrics_test.go @@ -0,0 +1,190 @@ +package metrics + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "chat/src/auth" + "chat/src/db" + "chat/src/ws" +) + +func TestHandleHealth(t *testing.T) { + handler := HandleHealth() + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, `"healthy"`) { + t.Errorf("body missing healthy: %s", body) + } + if !strings.Contains(body, `"chat"`) { + t.Errorf("body missing service name: %s", body) + } +} + +func TestHandleNotFound(t *testing.T) { + handler := HandleNotFound() + req := httptest.NewRequest("GET", "/nonexistent", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, `"not found"`) { + t.Errorf("body missing not found: %s", body) + } +} + +func TestHandleMetrics(t *testing.T) { + gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open test DB: %v", err) + } + gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}) + + // Add some test data + gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"}) + gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()}) + + sessionStore := auth.NewSessionStore() + hub := ws.NewHub(gormDB, nil, "test-instance", 10, 10*time.Second, "admin") + hub.DMsSent = 5 + + handler := HandleMetrics(gormDB, nil, hub, sessionStore, "sqlite", "test-instance") + req := httptest.NewRequest("GET", "/metrics", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + body := w.Body.String() + + // Check required metric names + requiredMetrics := []string{ + "linearvoid_chat_connected_clients", + "linearvoid_chat_messages_total", + "linearvoid_chat_dms_total", + "linearvoid_chat_moderation_actions_total", + "linearvoid_chat_redis_enabled", + "linearvoid_chat_redis_up", + "linearvoid_chat_redis_connected_clients", + "linearvoid_chat_admin_sessions_active", + "linearvoid_chat_build_info", + } + + for _, metric := range requiredMetrics { + if !strings.Contains(body, metric) { + t.Errorf("body missing metric %s", metric) + } + } + + // Check build_info has instance_id label + if !strings.Contains(body, `instance_id="test-instance"`) { + t.Error("build_info missing instance_id label") + } + + // Check values + if !strings.Contains(body, "linearvoid_chat_messages_total 1") { + t.Error("messages_total should be 1") + } + if !strings.Contains(body, "linearvoid_chat_moderation_actions_total 1") { + t.Error("moderation_actions_total should be 1") + } + if !strings.Contains(body, "linearvoid_chat_dms_total 5") { + t.Error("dms_total should be 5") + } + if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") { + t.Error("redis_enabled should be 0 (nil redis)") + } +} + +func TestHandleMetrics_WrongMethod(t *testing.T) { + handler := HandleMetrics(nil, nil, nil, nil, "", "") + req := httptest.NewRequest("POST", "/metrics", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", w.Code) + } +} + +func TestHandleMetrics_WithRedis(t *testing.T) { + gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}) + + // mockRedis for metrics test + mr := &metricsMockRedis{} + sessionStore := auth.NewSessionStore() + hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin") + + handler := HandleMetrics(gormDB, mr, hub, sessionStore, "sqlite", "inst-123") + req := httptest.NewRequest("GET", "/metrics", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "linearvoid_chat_redis_enabled 1") { + t.Error("redis_enabled should be 1") + } + if !strings.Contains(body, "linearvoid_chat_redis_up 1") { + t.Error("redis_up should be 1") + } + if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") { + t.Error("redis_connected_clients should be 5") + } +} + +// metricsMockRedis is a minimal mock for metrics tests. +type metricsMockRedis struct{} + +func (m *metricsMockRedis) Ping(ctx context.Context) error { return nil } +func (m *metricsMockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + return nil +} +func (m *metricsMockRedis) Get(ctx context.Context, key string) (string, error) { return "", nil } +func (m *metricsMockRedis) Del(ctx context.Context, keys ...string) error { return nil } +func (m *metricsMockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil } +func (m *metricsMockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil } +func (m *metricsMockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error { + return nil +} +func (m *metricsMockRedis) Exists(ctx context.Context, keys ...string) (int64, error) { return 0, nil } +func (m *metricsMockRedis) Publish(ctx context.Context, channel string, message interface{}) error { + return nil +} +func (m *metricsMockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub { return nil } +func (m *metricsMockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) { + return nil, 0, nil +} +func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) { + return "connected_clients:5\nused_memory_human:1M\n", nil +} +func (m *metricsMockRedis) Close() error { return nil } \ No newline at end of file diff --git a/backend/chat/src/ratelimit/ratelimit_test.go b/backend/chat/src/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..5164133 --- /dev/null +++ b/backend/chat/src/ratelimit/ratelimit_test.go @@ -0,0 +1,190 @@ +package ratelimit + +import ( + "context" + "sync" + "testing" + "time" +) + +// mockRedis implements RedisClient for testing. +type mockRedis struct { + mu sync.Mutex + store map[string]int64 +} + +func newMockRedis() *mockRedis { + return &mockRedis{store: make(map[string]int64)} +} + +func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.store[key]++ + return m.store[key], nil +} + +func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error { + return nil +} + +func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.store[key]-- + return m.store[key], nil +} + +func TestCheckMessageRate(t *testing.T) { + redis := newMockRedis() + ctx := context.Background() + + // First 10 messages allowed + for i := 0; i < 10; i++ { + allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second) + if err != nil { + t.Fatalf("CheckMessageRate #%d error = %v", i+1, err) + } + if !allowed { + t.Errorf("CheckMessageRate #%d = false, want true", i+1) + } + } + + // 11th message blocked + allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second) + if err != nil { + t.Fatalf("CheckMessageRate #11 error = %v", err) + } + if allowed { + t.Error("CheckMessageRate #11 = true, want false (rate limited)") + } + + // Different user not affected + allowed, err = CheckMessageRate(ctx, redis, "user2", 10, 10*time.Second) + if err != nil { + t.Fatalf("CheckMessageRate user2 error = %v", err) + } + if !allowed { + t.Error("CheckMessageRate user2 = false, want true") + } +} + +func TestCheckMessageRate_NilRedis(t *testing.T) { + allowed, err := CheckMessageRate(context.Background(), nil, "user1", 10, 10*time.Second) + if err != nil { + t.Fatalf("CheckMessageRate nil redis error = %v", err) + } + if !allowed { + t.Error("CheckMessageRate nil redis = false, want true (fail open)") + } +} + +func TestCheckLoginRate(t *testing.T) { + redis := newMockRedis() + ctx := context.Background() + + // First 5 attempts allowed + for i := 0; i < 5; i++ { + allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1") + if err != nil { + t.Fatalf("CheckLoginRate #%d error = %v", i+1, err) + } + if !allowed { + t.Errorf("CheckLoginRate #%d = false, want true", i+1) + } + } + + // 6th attempt blocked + allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1") + if err != nil { + t.Fatalf("CheckLoginRate #6 error = %v", err) + } + if allowed { + t.Error("CheckLoginRate #6 = true, want false (rate limited)") + } + + // Different IP not affected + allowed, err = CheckLoginRate(ctx, redis, "10.0.0.1") + if err != nil { + t.Fatalf("CheckLoginRate other IP error = %v", err) + } + if !allowed { + t.Error("CheckLoginRate other IP = false, want true") + } +} + +func TestCheckLoginRate_NilRedis(t *testing.T) { + allowed, err := CheckLoginRate(context.Background(), nil, "192.168.1.1") + if err != nil { + t.Fatalf("CheckLoginRate nil redis error = %v", err) + } + if !allowed { + t.Error("CheckLoginRate nil redis = false, want true (fail open)") + } +} + +func TestCheckWSConnRate(t *testing.T) { + redis := newMockRedis() + ctx := context.Background() + + // First 5 connections allowed + for i := 0; i < 5; i++ { + allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1") + if err != nil { + t.Fatalf("CheckWSConnRate #%d error = %v", i+1, err) + } + if !allowed { + t.Errorf("CheckWSConnRate #%d = false, want true", i+1) + } + } + + // 6th connection blocked + allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1") + if err != nil { + t.Fatalf("CheckWSConnRate #6 error = %v", err) + } + if allowed { + t.Error("CheckWSConnRate #6 = true, want false (rate limited)") + } +} + +func TestCheckWSConnRate_NilRedis(t *testing.T) { + allowed, err := CheckWSConnRate(context.Background(), nil, "192.168.1.1") + if err != nil { + t.Fatalf("CheckWSConnRate nil redis error = %v", err) + } + if !allowed { + t.Error("CheckWSConnRate nil redis = false, want true (fail open)") + } +} + +func TestDecrWSConn(t *testing.T) { + redis := newMockRedis() + ctx := context.Background() + + // Increment to 5 + for i := 0; i < 5; i++ { + CheckWSConnRate(ctx, redis, "192.168.1.1") + } + + // 6th should be blocked + allowed, _ := CheckWSConnRate(ctx, redis, "192.168.1.1") + if allowed { + t.Error("6th connection should be blocked") + } + + // Decrement twice (simulate 2 disconnects) + DecrWSConn(ctx, redis, "192.168.1.1") + DecrWSConn(ctx, redis, "192.168.1.1") + + // Now should be allowed again (counter 6→4, then incr→5) + allowed, _ = CheckWSConnRate(ctx, redis, "192.168.1.1") + if !allowed { + t.Error("after 2x DecrWSConn, connection should be allowed") + } +} + +func TestDecrWSConn_NilRedis(t *testing.T) { + // Should not panic + DecrWSConn(context.Background(), nil, "192.168.1.1") +} \ No newline at end of file diff --git a/backend/chat/src/ws/ws_test.go b/backend/chat/src/ws/ws_test.go new file mode 100644 index 0000000..42c6730 --- /dev/null +++ b/backend/chat/src/ws/ws_test.go @@ -0,0 +1,563 @@ +package ws + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/gorilla/websocket" + "gorm.io/gorm" + + "chat/src/db" +) + +// mockRedis implements db.RedisClient for testing. +type mockRedis struct { + mu sync.Mutex + store map[string]string + pubsubs []*mockPubSub +} + +func newMockRedis() *mockRedis { + return &mockRedis{store: make(map[string]string)} +} + +func (m *mockRedis) Ping(ctx context.Context) error { return nil } +func (m *mockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + m.mu.Lock() + defer m.mu.Unlock() + m.store[key] = value.(string) + return nil +} +func (m *mockRedis) Get(ctx context.Context, key string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.store[key], nil +} +func (m *mockRedis) Del(ctx context.Context, keys ...string) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, k := range keys { + delete(m.store, k) + } + return nil +} +func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil } +func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil } +func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error { + return nil +} +func (m *mockRedis) Exists(ctx context.Context, keys ...string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, k := range keys { + if _, ok := m.store[k]; ok { + return 1, nil + } + } + return 0, nil +} +func (m *mockRedis) Publish(ctx context.Context, channel string, message interface{}) error { + m.mu.Lock() + defer m.mu.Unlock() + payload, _ := json.Marshal(message) + for _, ps := range m.pubsubs { + ps.messages <- &db.PubSubMessage{Channel: channel, Payload: string(payload)} + } + return nil +} +func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub { + ps := &mockPubSub{messages: make(chan *db.PubSubMessage, 100)} + m.mu.Lock() + m.pubsubs = append(m.pubsubs, ps) + m.mu.Unlock() + return ps +} +func (m *mockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var keys []string + for k := range m.store { + if strings.Contains(k, strings.TrimSuffix(match, "*")) { + keys = append(keys, k) + } + } + return keys, 0, nil +} +func (m *mockRedis) Info(ctx context.Context, sections ...string) (string, error) { return "", nil } +func (m *mockRedis) Close() error { return nil } + +type mockPubSub struct { + messages chan *db.PubSubMessage +} + +func (m *mockPubSub) ReceiveMessage(ctx context.Context) (*db.PubSubMessage, error) { + select { + case msg := <-m.messages: + return msg, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} +func (m *mockPubSub) Close() error { return nil } + +func setupTestHub(t *testing.T) (*Hub, *gorm.DB, *mockRedis) { + t.Helper() + gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open test DB: %v", err) + } + gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}) + mr := newMockRedis() + hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin") + return hub, gormDB, mr +} + +func setupWSServer(hub *Hub) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(hub.HandleConnection)) +} + +// wsClient wraps a websocket.Conn with a channel-based read helper. +type wsClient struct { + conn *websocket.Conn + msgs chan db.Message + errors chan error + done chan struct{} +} + +func newWSClient(t *testing.T, url string) *wsClient { + t.Helper() + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + t.Fatalf("Dial error: %v", err) + } + wc := &wsClient{ + conn: conn, + msgs: make(chan db.Message, 100), + errors: make(chan error, 1), + done: make(chan struct{}), + } + go wc.readLoop() + return wc +} + +func (wc *wsClient) readLoop() { + defer close(wc.msgs) + for { + var msg db.Message + if err := wc.conn.ReadJSON(&msg); err != nil { + wc.errors <- err + return + } + select { + case wc.msgs <- msg: + case <-wc.done: + return + } + } +} + +func (wc *wsClient) close() { + close(wc.done) + wc.conn.Close() +} + +// recvMsg reads the next message with a timeout. +func (wc *wsClient) recvMsg(timeout time.Duration) (*db.Message, error) { + select { + case msg := <-wc.msgs: + return &msg, nil + case err := <-wc.errors: + return nil, err + case <-time.After(timeout): + return nil, context.DeadlineExceeded + } +} + +// skipSystem reads and discards system messages. +func (wc *wsClient) skipSystem(timeout time.Duration) { + for { + msg, err := wc.recvMsg(timeout) + if err != nil || msg.Type != "system" { + return + } + } +} + +func (wc *wsClient) writeJSON(v interface{}) error { + return wc.conn.WriteJSON(v) +} + +func TestHub_Broadcast(t *testing.T) { + hub, _, _ := setupTestHub(t) + + msg := db.Message{ + Room: "main", + Username: "user1", + Text: "hello", + Timestamp: time.Now(), + Type: "public", + } + hub.Broadcast(msg) + + var count int64 + hub.DB.Model(&db.Message{}).Count(&count) + if count != 1 { + t.Errorf("message count = %d, want 1", count) + } +} + +func TestHub_ConnectedClients(t *testing.T) { + hub, _, _ := setupTestHub(t) + if hub.ConnectedClients() != 0 { + t.Errorf("ConnectedClients() = %d, want 0", hub.ConnectedClients()) + } +} + +func TestHub_WebSocketConnection(t *testing.T) { + hub, _, _ := setupTestHub(t) + srv := setupWSServer(hub) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main" + wc := newWSClient(t, wsURL) + defer wc.close() + + msg, err := wc.recvMsg(2 * time.Second) + if err != nil { + t.Fatalf("recvMsg error: %v", err) + } + if !strings.Contains(msg.Text, "joined") { + t.Errorf("expected join message, got text=%s", msg.Text) + } + + if hub.ConnectedClients() != 1 { + t.Errorf("ConnectedClients() = %d, want 1", hub.ConnectedClients()) + } +} + +func TestHub_SendMessage(t *testing.T) { + hub, _, _ := setupTestHub(t) + srv := setupWSServer(hub) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main" + wc := newWSClient(t, wsURL) + defer wc.close() + + wc.skipSystem(2 * time.Second) + + if err := wc.writeJSON(db.Message{Room: "main", Text: "hello world"}); err != nil { + t.Fatalf("writeJSON error: %v", err) + } + + recvMsg, err := wc.recvMsg(2 * time.Second) + if err != nil { + t.Fatalf("recvMsg error: %v", err) + } + if recvMsg.Text != "hello world" { + t.Errorf("recvMsg.Text = %q, want hello world", recvMsg.Text) + } + if recvMsg.Username != "testuser" { + t.Errorf("recvMsg.Username = %q, want testuser", recvMsg.Username) + } + if recvMsg.Type != "public" { + t.Errorf("recvMsg.Type = %q, want public", recvMsg.Type) + } +} + +func TestHub_Disconnect(t *testing.T) { + hub, _, _ := setupTestHub(t) + srv := setupWSServer(hub) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main" + wc := newWSClient(t, wsURL) + wc.skipSystem(2 * time.Second) + + if hub.ConnectedClients() != 1 { + t.Errorf("ConnectedClients() = %d, want 1", hub.ConnectedClients()) + } + + wc.close() + time.Sleep(200 * time.Millisecond) + + if hub.ConnectedClients() != 0 { + t.Errorf("ConnectedClients() after close = %d, want 0", hub.ConnectedClients()) + } +} + +func TestHub_BannedUser(t *testing.T) { + hub, _, mr := setupTestHub(t) + srv := setupWSServer(hub) + defer srv.Close() + + mr.Set(context.Background(), "banned:testuser", "1", 0) + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main" + wc := newWSClient(t, wsURL) + defer wc.close() + + wc.skipSystem(2 * time.Second) + wc.writeJSON(db.Message{Room: "main", Text: "hello"}) + + recvMsg, err := wc.recvMsg(2 * time.Second) + if err != nil { + t.Fatalf("recvMsg error: %v", err) + } + if !strings.Contains(recvMsg.Text, "banned") { + t.Errorf("expected banned message, got %q", recvMsg.Text) + } +} + +func TestHub_MutedUser(t *testing.T) { + hub, _, mr := setupTestHub(t) + srv := setupWSServer(hub) + defer srv.Close() + + mr.Set(context.Background(), "muted:testuser", "1", 0) + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main" + wc := newWSClient(t, wsURL) + defer wc.close() + + wc.skipSystem(2 * time.Second) + wc.writeJSON(db.Message{Room: "main", Text: "hello"}) + + recvMsg, err := wc.recvMsg(2 * time.Second) + if err != nil { + t.Fatalf("recvMsg error: %v", err) + } + if !strings.Contains(recvMsg.Text, "muted") { + t.Errorf("expected muted message, got %q", recvMsg.Text) + } +} + +func TestHub_ModerationCommand(t *testing.T) { + hub, _, _ := setupTestHub(t) + srv := setupWSServer(hub) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=admin&room=main" + wc := newWSClient(t, wsURL) + defer wc.close() + + wc.skipSystem(2 * time.Second) + wc.writeJSON(db.Message{Room: "main", Text: "/mute baduser 5 spamming"}) + + recvMsg, err := wc.recvMsg(2 * time.Second) + if err != nil { + t.Fatalf("recvMsg error: %v", err) + } + if !strings.Contains(recvMsg.Text, "mute") { + t.Errorf("expected mute broadcast, got text=%s", recvMsg.Text) + } + + var count int64 + hub.DB.Model(&db.ModerationLog{}).Count(&count) + if count != 1 { + t.Errorf("moderation log count = %d, want 1", count) + } +} + +func TestHub_NonAdminModerationCommand(t *testing.T) { + hub, _, _ := setupTestHub(t) + srv := setupWSServer(hub) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=regularuser&room=main" + wc := newWSClient(t, wsURL) + defer wc.close() + + wc.skipSystem(2 * time.Second) + wc.writeJSON(db.Message{Room: "main", Text: "/mute baduser 5 spamming"}) + + recvMsg, err := wc.recvMsg(2 * time.Second) + if err != nil { + t.Fatalf("recvMsg error: %v", err) + } + if !strings.Contains(recvMsg.Text, "Only moderators") { + t.Errorf("expected moderator-only message, got %q", recvMsg.Text) + } +} + +func TestHub_PrivateDM(t *testing.T) { + hub, _, _ := setupTestHub(t) + srv := setupWSServer(hub) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + wc1 := newWSClient(t, wsURL+"?username=alice&room=main") + defer wc1.close() + wc2 := newWSClient(t, wsURL+"?username=bob&room=main") + defer wc2.close() + + wc1.skipSystem(2 * time.Second) + wc2.skipSystem(2 * time.Second) + + wc1.writeJSON(db.Message{Room: "dm:bob", Text: "hey bob"}) + + recvMsg, err := wc2.recvMsg(2 * time.Second) + if err != nil { + t.Fatalf("bob recvMsg error: %v", err) + } + if recvMsg.Type != "private" { + t.Errorf("expected private type, got %s", recvMsg.Type) + } + if recvMsg.Text != "hey bob" { + t.Errorf("expected 'hey bob', got %q", recvMsg.Text) + } + + recvMsg2, err := wc1.recvMsg(2 * time.Second) + if err != nil { + t.Fatalf("alice recvMsg error: %v", err) + } + if recvMsg2.Type != "private" { + t.Errorf("expected private confirmation, got %s", recvMsg2.Type) + } +} + +func TestHub_PushChatHistory(t *testing.T) { + hub, gormDB, _ := setupTestHub(t) + + for i := 0; i < 5; i++ { + gormDB.Create(&db.Message{ + Room: "main", + Username: "user1", + Text: "msg", + Timestamp: time.Now().Add(time.Duration(i) * time.Second), + Type: "public", + }) + } + + srv := setupWSServer(hub) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=newuser&room=main" + wc := newWSClient(t, wsURL) + defer wc.close() + + count := 0 + for i := 0; i < 10; i++ { + msg, err := wc.recvMsg(2 * time.Second) + if err != nil { + break + } + if msg.Type == "public" { + count++ + } + } + if count != 5 { + t.Errorf("history message count = %d, want 5", count) + } +} + +func TestHub_MuteUser_UnmuteUser(t *testing.T) { + hub, _, mr := setupTestHub(t) + ctx := context.Background() + + err := hub.MuteUser(ctx, "user1", 10*time.Minute) + if err != nil { + t.Fatalf("MuteUser error = %v", err) + } + + exists, _ := mr.Exists(ctx, "muted:user1") + if exists != 1 { + t.Error("muted key not found after MuteUser") + } + + err = hub.UnmuteUser(ctx, "user1") + if err != nil { + t.Fatalf("UnmuteUser error = %v", err) + } + + exists, _ = mr.Exists(ctx, "muted:user1") + if exists != 0 { + t.Error("muted key still exists after UnmuteUser") + } +} + +func TestHub_BanUser_UnbanUser(t *testing.T) { + hub, _, mr := setupTestHub(t) + ctx := context.Background() + + err := hub.BanUser(ctx, "user1") + if err != nil { + t.Fatalf("BanUser error = %v", err) + } + + exists, _ := mr.Exists(ctx, "banned:user1") + if exists != 1 { + t.Error("banned key not found after BanUser") + } + + err = hub.UnbanUser(ctx, "user1") + if err != nil { + t.Fatalf("UnbanUser error = %v", err) + } + + exists, _ = mr.Exists(ctx, "banned:user1") + if exists != 0 { + t.Error("banned key still exists after UnbanUser") + } +} + +func TestHub_ScanPresence(t *testing.T) { + hub, _, mr := setupTestHub(t) + ctx := context.Background() + + mr.Set(ctx, "presence:main:user1", "online", 0) + mr.Set(ctx, "presence:main:user2", "online", 0) + mr.Set(ctx, "presence:lobby:user3", "online", 0) + + presences, err := hub.ScanPresence(ctx) + if err != nil { + t.Fatalf("ScanPresence error = %v", err) + } + if len(presences) != 3 { + t.Errorf("ScanPresence len = %d, want 3", len(presences)) + } +} + +func TestHub_SubscribeRoomMessages(t *testing.T) { + hub, _, mr := setupTestHub(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + hub.SubscribeRoomMessages() + }() + + payload, _ := json.Marshal(map[string]any{ + "instance": "other-instance", + "msg": db.Message{ + Room: "main", + Username: "remoteuser", + Text: "remote hello", + Timestamp: time.Now(), + Type: "public", + }, + }) + mr.Publish(ctx, "room:main:messages", string(payload)) + time.Sleep(50 * time.Millisecond) + + ownPayload, _ := json.Marshal(map[string]any{ + "instance": "test-instance", + "msg": db.Message{ + Room: "main", + Username: "localuser", + Text: "local hello", + Timestamp: time.Now(), + Type: "public", + }, + }) + mr.Publish(ctx, "room:main:messages", string(ownPayload)) + time.Sleep(50 * time.Millisecond) +} \ No newline at end of file diff --git a/backend/playout/src/go.mod b/backend/playout/src/go.mod new file mode 100644 index 0000000..8f80ddb --- /dev/null +++ b/backend/playout/src/go.mod @@ -0,0 +1,27 @@ +module playout + +go 1.26.5 + +require ( + github.com/aws/aws-sdk-go-v2 v1.43.4 + github.com/aws/aws-sdk-go-v2/config v1.32.35 + github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.34 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect + github.com/aws/smithy-go v1.27.6 // indirect +) diff --git a/backend/playout/src/go.sum b/backend/playout/src/go.sum new file mode 100644 index 0000000..b0e9369 --- /dev/null +++ b/backend/playout/src/go.sum @@ -0,0 +1,36 @@ +github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= +github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I= +github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E= +github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g= +github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0 h1:OkYV+1171za+ab9otU1tGxMXhx6uZvwVEtVddjLuYTg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc= +github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= +github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= diff --git a/backend/playout/src/playlist_test.go b/backend/playout/src/playlist_test.go new file mode 100644 index 0000000..5d06215 --- /dev/null +++ b/backend/playout/src/playlist_test.go @@ -0,0 +1,353 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadPlaylist(t *testing.T) { + tmpDir := t.TempDir() + + tests := []struct { + name string + content string + wantLen int + wantErr bool + first string + }{ + { + name: "simple paths", + content: "/videos/video1.mp4\n/videos/video2.mp4\n", + wantLen: 2, + first: "/videos/video1.mp4", + }, + { + name: "with file prefix", + content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n", + wantLen: 2, + first: "/videos/video1.mp4", + }, + { + name: "with file prefix double quotes", + content: "file \"/videos/video1.mp4\"\n", + wantLen: 1, + first: "/videos/video1.mp4", + }, + { + name: "with comments and blanks", + content: "# comment\n\n/videos/video1.mp4\n\n# another\n/videos/video2.mp4\n", + wantLen: 2, + first: "/videos/video1.mp4", + }, + { + name: "empty file", + content: "", + wantLen: 0, + }, + { + name: "only comments", + content: "# comment\n# another\n", + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(tmpDir, tt.name+".txt") + os.WriteFile(path, []byte(tt.content), 0644) + + videos, err := LoadPlaylist(path) + if tt.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(videos) != tt.wantLen { + t.Errorf("len = %d, want %d", len(videos), tt.wantLen) + } + if tt.wantLen > 0 && videos[0].Path != tt.first { + t.Errorf("videos[0].Path = %q, want %q", videos[0].Path, tt.first) + } + }) + } +} + +func TestLoadPlaylist_FileNotFound(t *testing.T) { + _, err := LoadPlaylist("/nonexistent/playlist.txt") + if err == nil { + t.Error("expected error for missing file, got nil") + } +} + +func TestValidateKeyframeInterval(t *testing.T) { + tests := []struct { + name string + csvData string + wantErr bool + errText string + }{ + { + name: "valid keyframes (gaps <=2s)", + csvData: "1,0.00\n0,0.50\n1,1.00\n0,1.50\n1,2.00\n0,2.50\n1,3.00\n", + wantErr: false, + }, + { + name: "gap too large >2s", + csvData: "1,0.00\n0,1.00\n1,3.50\n0,4.00\n1,6.00\n", + wantErr: true, + errText: "keyframe interval too large", + }, + { + name: "insufficient keyframes (<2)", + csvData: "1,0.00\n", + wantErr: true, + errText: "insufficient keyframes", + }, + { + name: "empty output", + csvData: "", + wantErr: true, + errText: "keyframe probe failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // create temp ffprobe that outputs csvData + tmpDir := t.TempDir() + mockProbe := filepath.Join(tmpDir, "ffprobe") + var script string + if tt.csvData == "" { + script = "@echo off\necho\nexit /b 1\n" + } else { + script = "@echo off\necho " + strings.ReplaceAll(tt.csvData, "\n", "& echo ") + "\nexit /b 0\n" + } + os.WriteFile(mockProbe+".bat", []byte(script), 0755) + os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + defer os.Unsetenv("PATH") + + err := validateKeyframeInterval("test.mp4") + if tt.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + if tt.wantErr && tt.errText != "" && err != nil { + if !strings.Contains(err.Error(), tt.errText) { + t.Errorf("error = %v, want containing %q", err, tt.errText) + } + } + }) + } +} + +func TestValidateVideoStrict(t *testing.T) { + // ValidateVideoStrict calls ValidateVideo which calls ffprobe + // Test only the result structure when ValidateVideo returns valid=false + // Mock not feasible without full ffprobe mock, skip for now + t.Skip("requires ffprobe mock or test media fixtures") +} + +func TestHealthHandler(t *testing.T) { + // Save and restore globals + prevMode := playoutMode + prevCount := restartCount + playoutMode = "live" + restartCount = 3 + defer func() { + playoutMode = prevMode + restartCount = prevCount + }() + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + HealthHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, `"healthy"`) { + t.Errorf("body missing healthy: %s", body) + } + if !strings.Contains(body, `"playout"`) { + t.Errorf("body missing service: %s", body) + } + if !strings.Contains(body, `"restarts":3`) { + t.Errorf("body missing restarts: %s", body) + } +} + +func TestHealthHandler_Degraded(t *testing.T) { + prevMode := playoutMode + playoutMode = "slate" + defer func() { playoutMode = prevMode }() + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + HealthHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, `"degraded"`) { + t.Errorf("body missing degraded: %s", body) + } +} + +func TestShouldEscalateToSlate(t *testing.T) { + // Save globals + prevCount := restartCount + prevWindow := restartWindow + defer func() { + restartCount = prevCount + restartWindow = prevWindow + }() + + // Under threshold + restartWindow = time.Now() + restartCount = 3 + if shouldEscalateToSlate() { + t.Error("shouldEscalateToSlate() = true, want false (under threshold)") + } + + // At threshold + restartCount = maxRestartsPerMinute + if !shouldEscalateToSlate() { + t.Error("shouldEscalateToSlate() = false, want true (at threshold)") + } + + // Over threshold + restartCount = 7 + if !shouldEscalateToSlate() { + t.Error("shouldEscalateToSlate() = false, want true (over threshold)") + } + + // Window expired + restartWindow = time.Now().Add(-61 * time.Second) + restartCount = 10 + if shouldEscalateToSlate() { + t.Error("shouldEscalateToSlate() = true, want false (window expired, should reset)") + } + if restartCount != 0 { + t.Errorf("restartCount after window reset = %d, want 0", restartCount) + } +} + +func TestResetHLSOutput(t *testing.T) { + tmpDir := t.TempDir() + + // Create some segment files + os.MkdirAll(tmpDir, 0755) + os.WriteFile(filepath.Join(tmpDir, "segment_test.ts"), []byte("data"), 0644) + os.WriteFile(filepath.Join(tmpDir, "segment_test.m4s"), []byte("data"), 0644) + os.WriteFile(filepath.Join(tmpDir, "playlist.m3u8"), []byte("#EXTM3U"), 0644) + os.WriteFile(filepath.Join(tmpDir, "init.mp4"), []byte("init"), 0644) + + err := resetHLSOutput(tmpDir) + if err != nil { + t.Fatalf("resetHLSOutput error: %v", err) + } + + // Verify all cleaned + for _, name := range []string{"segment_test.ts", "segment_test.m4s", "playlist.m3u8", "init.mp4"} { + if _, err := os.Stat(filepath.Join(tmpDir, name)); !os.IsNotExist(err) { + t.Errorf("file %s still exists after reset", name) + } + } +} + +func TestResetHLSOutput_EmptyDir(t *testing.T) { + tmpDir := t.TempDir() + err := resetHLSOutput(tmpDir) + if err != nil { + t.Fatalf("resetHLSOutput on empty dir error: %v", err) + } +} + +func TestActivateSlate_NoSlate(t *testing.T) { + prevSlateDir := slateDir + prevHLSDir := hlsDir + prevMode := playoutMode + tmpDir := t.TempDir() + slateDir = filepath.Join(tmpDir, "slate") + hlsDir = filepath.Join(tmpDir, "hls") + os.MkdirAll(hlsDir, 0755) + defer func() { + slateDir = prevSlateDir + hlsDir = prevHLSDir + playoutMode = prevMode + }() + + err := activateSlate() + if err == nil { + t.Error("expected error when slate HLS missing, got nil") + } +} + +func TestActivateSlate_Success(t *testing.T) { + prevSlateDir := slateDir + prevHLSDir := hlsDir + prevMode := playoutMode + tmpDir := t.TempDir() + slateDir = filepath.Join(tmpDir, "slate") + hlsDir = filepath.Join(tmpDir, "hls") + + // Create slate HLS structure + slateHLS := filepath.Join(slateDir, "hls") + os.MkdirAll(slateHLS, 0755) + os.MkdirAll(hlsDir, 0755) + os.WriteFile(filepath.Join(slateHLS, "slate.m3u8"), []byte("#EXTM3U\n#EXT-X-ENDLIST\n"), 0644) + os.WriteFile(filepath.Join(slateHLS, "slate_segment.ts"), []byte("slate-data"), 0644) + + defer func() { + slateDir = prevSlateDir + hlsDir = prevHLSDir + playoutMode = prevMode + }() + + err := activateSlate() + if err != nil { + t.Fatalf("activateSlate error: %v", err) + } + + if playoutMode != "slate" { + t.Errorf("playoutMode = %q, want slate", playoutMode) + } + + // Verify files copied + if _, err := os.Stat(filepath.Join(hlsDir, "slate.m3u8")); os.IsNotExist(err) { + t.Error("slate.m3u8 not copied to hls dir") + } +} + +func TestDeactivateSlate(t *testing.T) { + prevMode := playoutMode + playoutMode = "slate" + defer func() { playoutMode = prevMode }() + + deactivateSlate() + + if playoutMode != "live" { + t.Errorf("playoutMode = %q, want live", playoutMode) + } +} + +func TestValidatePlaylist(t *testing.T) { + // ValidatePlaylist calls ValidateVideo which needs ffprobe + t.Skip("requires ffprobe mock or test media fixtures") +} + +func TestSyncToR2(t *testing.T) { + // Requires R2/S3 mock + t.Skip("requires S3 client mock") +} \ No newline at end of file