197 lines
5.3 KiB
Go
197 lines
5.3 KiB
Go
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")
|
|
}
|
|
}
|