package ws import ( "encoding/json" "net/http/httptest" "testing" "time" "github.com/edu-cloud/shared-go/jwks" "github.com/golang-jwt/jwt/v5" ) // TestBuildMessage verifies the WebSocket message envelope format (02 ยง4.1). func TestBuildMessage(t *testing.T) { data := map[string]any{"title": "hello", "body": "world"} msg, err := buildMessage("notification.created", data) if err != nil { t.Fatalf("buildMessage: %v", err) } var payload map[string]any if err := json.Unmarshal(msg, &payload); err != nil { t.Fatalf("unmarshal: %v", err) } if payload["type"] != "message" { t.Errorf("type = %v, want message", payload["type"]) } if payload["event"] != "notification.created" { t.Errorf("event = %v, want notification.created", payload["event"]) } if payload["timestamp"] == nil { t.Error("timestamp missing") } dataField, ok := payload["data"].(map[string]any) if !ok { t.Fatalf("data should be a map, got %T", payload["data"]) } if dataField["title"] != "hello" { t.Errorf("data.title = %v, want hello", dataField["title"]) } } // TestBuildMessageNilData verifies nil data is handled gracefully. func TestBuildMessageNilData(t *testing.T) { msg, err := buildMessage("test.event", nil) if err != nil { t.Fatalf("buildMessage: %v", err) } var payload map[string]any if err := json.Unmarshal(msg, &payload); err != nil { t.Fatalf("unmarshal: %v", err) } if payload["data"] != nil { t.Errorf("data = %v, want nil", payload["data"]) } } // TestMustMarshal verifies best-effort JSON encoding. func TestMustMarshal(t *testing.T) { // nil -> "null" if got := mustMarshal(nil); string(got) != "null" { t.Errorf("mustMarshal(nil) = %q, want null", got) } // valid map -> JSON got := mustMarshal(map[string]any{"a": 1}) var m map[string]any if err := json.Unmarshal(got, &m); err != nil { t.Fatalf("mustMarshal result unmarshal: %v", err) } if m["a"].(float64) != 1 { t.Errorf("mustMarshal.a = %v, want 1", m["a"]) } } // TestBuildOriginChecker verifies origin whitelist logic. func TestBuildOriginChecker(t *testing.T) { allowed := map[string]struct{}{ "http://localhost:3000": {}, "https://app.example.com": {}, } // Non-devMode: strict whitelist. checker := buildOriginChecker(allowed, false) tests := []struct { name string origin string want bool }{ {"empty origin (non-browser)", "", true}, {"allowed origin", "http://localhost:3000", true}, {"another allowed", "https://app.example.com", true}, {"disallowed origin", "http://evil.com", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("GET", "/ws", nil) if tc.origin != "" { req.Header.Set("Origin", tc.origin) } if got := checker(req); got != tc.want { t.Errorf("checker(origin=%q) = %v, want %v", tc.origin, got, tc.want) } }) } // DevMode with empty whitelist: allow all. devChecker := buildOriginChecker(map[string]struct{}{}, true) req := httptest.NewRequest("GET", "/ws", nil) req.Header.Set("Origin", "http://anything.com") if !devChecker(req) { t.Error("devMode empty whitelist should allow all origins") } } // TestValidateHS256 verifies HS256 JWT validation in DevMode fallback. func TestValidateHS256(t *testing.T) { secret := "test-secret" // Valid token with user_id claim. claims := jwks.Claims{ UserID: "user-123", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenStr, err := token.SignedString([]byte(secret)) if err != nil { t.Fatalf("SignedString: %v", err) } userID, err := validateHS256(tokenStr, secret) if err != nil { t.Fatalf("validateHS256: %v", err) } if userID != "user-123" { t.Errorf("userID = %q, want user-123", userID) } // Wrong secret -> error. _, err = validateHS256(tokenStr, "wrong-secret") if err == nil { t.Error("validateHS256 with wrong secret should fail") } // Expired token -> error. expiredClaims := jwks.Claims{ UserID: "user-exp", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), }, } expiredToken := jwt.NewWithClaims(jwt.SigningMethodHS256, expiredClaims) expiredStr, _ := expiredToken.SignedString([]byte(secret)) _, err = validateHS256(expiredStr, secret) if err == nil { t.Error("validateHS256 with expired token should fail") } // Missing user_id -> error. noUserClaims := jwks.Claims{ RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), }, } noUserToken := jwt.NewWithClaims(jwt.SigningMethodHS256, noUserClaims) noUserStr, _ := noUserToken.SignedString([]byte(secret)) _, err = validateHS256(noUserStr, secret) if err != errMissingUserID { t.Errorf("validateHS256 without user_id err = %v, want errMissingUserID", err) } } // TestContextWithTimeout verifies the helper returns a working context. func TestContextWithTimeout(t *testing.T) { ctx, cancel := contextWithTimeout(100 * time.Millisecond) defer cancel() if ctx == nil { t.Fatal("context is nil") } // Context should not be done immediately. select { case <-ctx.Done(): t.Fatal("context done immediately") default: } // Wait for timeout. time.Sleep(150 * time.Millisecond) select { case <-ctx.Done(): // expected default: t.Error("context not done after timeout") } }