package live import ( "context" "crypto/ecdsa" "crypto/x509" "encoding/pem" "fmt" "net/http/httptest" "net/url" "testing" "time" "github.com/centrifugal/centrifuge" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util/testutil" ) func TestMain(m *testing.M) { testsuite.Run(m) } func TestIntegration_provideLiveService_RedisUnavailable(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) cfg := setting.NewCfg() cfg.LiveHAEngine = "testredisunavailable" _, err := setupLiveService(cfg, t) // Proceeds without live HA if redis is unavailable require.NoError(t, err) } func Test_runConcurrentlyIfNeeded_Concurrent(t *testing.T) { doneCh := make(chan struct{}) f := func() { close(doneCh) } semaphore := make(chan struct{}, 2) err := runConcurrentlyIfNeeded(context.Background(), semaphore, f) require.NoError(t, err) select { case <-doneCh: case <-time.After(time.Second): t.Fatal("timeout waiting for function execution") } } func Test_runConcurrentlyIfNeeded_NoConcurrency(t *testing.T) { doneCh := make(chan struct{}) f := func() { close(doneCh) } err := runConcurrentlyIfNeeded(context.Background(), nil, f) require.NoError(t, err) select { case <-doneCh: case <-time.After(time.Second): t.Fatal("timeout waiting for function execution") } } func Test_runConcurrentlyIfNeeded_DeadlineExceeded(t *testing.T) { f := func() {} semaphore := make(chan struct{}, 2) semaphore <- struct{}{} semaphore <- struct{}{} ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) defer cancel() err := runConcurrentlyIfNeeded(ctx, semaphore, f) require.ErrorIs(t, err, context.DeadlineExceeded) } func TestCheckOrigin(t *testing.T) { testCases := []struct { name string origin string appURL string allowedOrigins []string success bool host string }{ { name: "empty_origin", origin: "", appURL: "http://localhost:3000/", success: true, }, { name: "valid_origin", origin: "http://localhost:3000", appURL: "http://localhost:3000/", success: true, }, { name: "valid_origin_no_port", origin: "https://www.example.com", appURL: "https://www.example.com:443/grafana/", success: true, }, { name: "unauthorized_origin", origin: "http://localhost:8000", appURL: "http://localhost:3000/", success: false, }, { name: "bad_origin", origin: ":::http://localhost:8000", appURL: "http://localhost:3000/", success: false, }, { name: "different_scheme", origin: "http://example.com", appURL: "https://example.com", success: false, }, { name: "authorized_case_insensitive", origin: "https://examplE.com", appURL: "https://example.com", success: true, }, { name: "authorized_allowed_origins", origin: "https://test.example.com", appURL: "http://localhost:3000/", allowedOrigins: []string{"https://test.example.com"}, success: true, }, { name: "authorized_allowed_origins_pattern", origin: "https://test.example.com", appURL: "http://localhost:3000/", allowedOrigins: []string{"https://*.example.com"}, success: true, }, { name: "authorized_allowed_origins_all", origin: "https://test.example.com", appURL: "http://localhost:3000/", allowedOrigins: []string{"*"}, success: true, }, { name: "request_host_matches_origin_host", origin: "http://example.com", appURL: "https://example.com", success: true, host: "example.com", }, } for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() appURL, err := url.Parse(tc.appURL) require.NoError(t, err) originGlobs, err := setting.GetAllowedOriginGlobs(tc.allowedOrigins) require.NoError(t, err) checkOrigin := getCheckOriginFunc(appURL, tc.allowedOrigins, originGlobs) r := httptest.NewRequest("GET", tc.appURL, nil) r.Host = tc.host r.Header.Set("Origin", tc.origin) require.Equal(t, tc.success, checkOrigin(r), "origin %s, appURL: %s", tc.origin, tc.appURL, ) }) } } func Test_getHistogramMetric(t *testing.T) { type args struct { val int bounds []int metricPrefix string } tests := []struct { name string args args want string }{ { "zero", args{0, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"}, "live_users_le_0", }, { "equal_to_bound", args{10, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"}, "live_users_le_10", }, { "in_the_middle", args{30000, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"}, "live_users_le_100000", }, { "more_than_upper_bound", args{300000, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"}, "live_users_le_inf", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := getHistogramMetric(tt.args.val, tt.args.bounds, tt.args.metricPrefix); got != tt.want { t.Errorf("getHistogramMetric() = %v, want %v", got, tt.want) } }) } } func Test_handleOnPublish_IDTokenExpiration(t *testing.T) { g, err := setupLiveService(nil, t) require.NoError(t, err) client, _, err := centrifuge.NewClient(context.Background(), g.node, newDummyTransport("test")) require.NoError(t, err) t.Run("expired token", func(t *testing.T) { expiration := time.Now().Add(-time.Hour) token := createToken(t, &expiration) ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{IDToken: token}) reply, err := g.handleOnPublish(ctx, client, centrifuge.PublishEvent{ Channel: "test", Data: []byte("test"), }) require.ErrorIs(t, err, centrifuge.ErrorExpired) require.Empty(t, reply) }) t.Run("unexpired token", func(t *testing.T) { expiration := time.Now().Add(time.Hour) token := createToken(t, &expiration) ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{IDToken: token}) reply, err := g.handleOnPublish(ctx, client, centrifuge.PublishEvent{ Channel: "test", Data: []byte("test"), }) // Another error is returned if the token is not expired but the refresh fails. // That happens because we're providing an invalid orgID as the channel. require.NotErrorIs(t, err, centrifuge.ErrorExpired) require.Empty(t, reply) }) } func Test_handleOnRPC_IDTokenExpiration(t *testing.T) { g, err := setupLiveService(nil, t) require.NoError(t, err) client, _, err := centrifuge.NewClient(context.Background(), g.node, newDummyTransport("test")) require.NoError(t, err) t.Run("expired token", func(t *testing.T) { expiration := time.Now().Add(-time.Hour) token := createToken(t, &expiration) ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{IDToken: token}) reply, err := g.handleOnRPC(ctx, client, centrifuge.RPCEvent{ Method: "grafana.query", Data: []byte("test"), }) require.ErrorIs(t, err, centrifuge.ErrorExpired) require.Empty(t, reply) }) t.Run("unexpired token", func(t *testing.T) { expiration := time.Now().Add(time.Hour) token := createToken(t, &expiration) ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{IDToken: token}) reply, err := g.handleOnRPC(ctx, client, centrifuge.RPCEvent{ Method: "grafana.query", Data: []byte("test"), }) // Another error is returned if the token is not expired but the refresh fails. // That happens because we're providing an invalid orgID as the channel. require.NotErrorIs(t, err, centrifuge.ErrorExpired) require.Empty(t, reply) }) } func Test_handleOnSubscribe_IDTokenExpiration(t *testing.T) { g, err := setupLiveService(nil, t) require.NoError(t, err) client, _, err := centrifuge.NewClient(context.Background(), g.node, newDummyTransport("test")) require.NoError(t, err) t.Run("expired token", func(t *testing.T) { expiration := time.Now().Add(-time.Hour) token := createToken(t, &expiration) ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{IDToken: token}) reply, err := g.handleOnSubscribe(ctx, client, centrifuge.SubscribeEvent{ Channel: "test", }) require.ErrorIs(t, err, centrifuge.ErrorExpired) require.Empty(t, reply) }) t.Run("unexpired token", func(t *testing.T) { expiration := time.Now().Add(time.Hour) token := createToken(t, &expiration) ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{IDToken: token}) reply, err := g.handleOnSubscribe(ctx, client, centrifuge.SubscribeEvent{ Channel: "test", }) // Another error is returned if the token is not expired but the refresh fails. // That happens because we're providing an invalid orgID as the channel. require.NotErrorIs(t, err, centrifuge.ErrorExpired) require.Empty(t, reply) }) } func setupLiveService(cfg *setting.Cfg, t *testing.T) (*GrafanaLive, error) { if cfg == nil { cfg = setting.NewCfg() } return ProvideService(cfg, routing.NewRouteRegister(), nil, nil, nil, nil, &usagestats.UsageStatsMock{T: t}, featuremgmt.WithFeatures(), &dashboards.FakeDashboardService{}, nil) } type dummyTransport struct { name string } var ( _ centrifuge.Transport = (*dummyTransport)(nil) ) func (t *dummyTransport) Name() string { return t.name } func (t *dummyTransport) AcceptProtocol() string { return "" } func (t *dummyTransport) Protocol() centrifuge.ProtocolType { return centrifuge.ProtocolTypeJSON } func (t *dummyTransport) ProtocolVersion() centrifuge.ProtocolVersion { return centrifuge.ProtocolVersion2 } func (t *dummyTransport) Emulation() bool { return false } func (t *dummyTransport) Unidirectional() bool { return false } func (t *dummyTransport) DisabledPushFlags() uint64 { return 0 } func (t *dummyTransport) PingPongConfig() centrifuge.PingPongConfig { return centrifuge.PingPongConfig{} } func (t *dummyTransport) Write(data []byte) error { return nil } func (t *dummyTransport) WriteMany(d ...[]byte) error { return nil } func (t *dummyTransport) Close(disconnect centrifuge.Disconnect) error { return nil } func newDummyTransport(name string) *dummyTransport { return &dummyTransport{name: name} } // There is a duplication of this function in the identity package. pkg/apimachinery/identity/requester_test.go. // If you need to copy it, place it as a test helper function in the identity package. var testKey = decodePrivateKey([]byte(` -----BEGIN EC PRIVATE KEY----- MHcCAQEEID6lXWsmcv/UWn9SptjOThsy88cifgGIBj2Lu0M9I8tQoAoGCCqGSM49 AwEHoUQDQgAEsf6eNnNMNhl+q7jXsbdUf3ADPh248uoFUSSV9oBzgptyokHCjJz6 n6PKDm2W7i3S2+dAs5M5f3s7d8KiLjGZdQ== -----END EC PRIVATE KEY----- `)) func decodePrivateKey(data []byte) *ecdsa.PrivateKey { block, _ := pem.Decode(data) if block == nil { panic("should include PEM block") } privateKey, err := x509.ParseECPrivateKey(block.Bytes) if err != nil { panic(fmt.Sprintf("should be able to parse ec private key: %v", err)) } if privateKey.Curve.Params().Name != "P-256" { panic("should be valid private key") } return privateKey } func createToken(t *testing.T, exp *time.Time) string { signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: testKey}, nil) require.NoError(t, err) claims := struct { jwt.Claims }{ Claims: jwt.Claims{ Subject: "test-user", }, } if exp != nil { claims.Expiry = jwt.NewNumericDate(*exp) } token, err := jwt.Signed(signer).Claims(claims).Serialize() require.NoError(t, err) return token }