/** * Standalone signaling server for the Nextcloud Spreed app. * Copyright (C) 2022 struktur AG * * @author Joachim Bauch * * @license GNU AGPL version 3 or any later version * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package signaling import ( "context" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "sync" "sync/atomic" "testing" "time" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func NewCapabilitiesForTestWithCallback(t *testing.T, callback func(*CapabilitiesResponse, http.ResponseWriter) error) (*url.URL, *Capabilities) { require := require.New(t) pool, err := NewHttpClientPool(1, false) require.NoError(err) capabilities, err := NewCapabilities("0.0", pool) require.NoError(err) r := mux.NewRouter() server := httptest.NewServer(r) t.Cleanup(func() { server.Close() }) u, err := url.Parse(server.URL) require.NoError(err) handleCapabilitiesFunc := func(w http.ResponseWriter, r *http.Request) { features := []string{ "foo", "bar", } if strings.Contains(t.Name(), "V3Api") { features = append(features, "signaling-v3") } signaling := map[string]interface{}{ "foo": "bar", "baz": 42, } config := map[string]interface{}{ "signaling": signaling, } spreedCapa, _ := json.Marshal(map[string]interface{}{ "features": features, "config": config, }) emptyArray := []byte("[]") response := &CapabilitiesResponse{ Version: CapabilitiesVersion{ Major: 20, }, Capabilities: map[string]json.RawMessage{ "anotherApp": emptyArray, "spreed": spreedCapa, }, } data, err := json.Marshal(response) assert.NoError(t, err, "Could not marshal %+v", response) var ocs OcsResponse ocs.Ocs = &OcsBody{ Meta: OcsMeta{ Status: "ok", StatusCode: http.StatusOK, Message: http.StatusText(http.StatusOK), }, Data: data, } data, err = json.Marshal(ocs) require.NoError(err) var cc []string if !strings.Contains(t.Name(), "NoCache") { if strings.Contains(t.Name(), "ShortCache") { cc = append(cc, "max-age=1") } else { cc = append(cc, "max-age=60") } } if strings.Contains(t.Name(), "MustRevalidate") && !strings.Contains(t.Name(), "NoMustRevalidate") { cc = append(cc, "must-revalidate") } if len(cc) > 0 { w.Header().Add("Cache-Control", strings.Join(cc, ", ")) } if strings.Contains(t.Name(), "ETag") { h := sha256.New() h.Write(data) // nolint etag := fmt.Sprintf("\"%s\"", base64.StdEncoding.EncodeToString(h.Sum(nil))) w.Header().Add("ETag", etag) if inm := r.Header.Get("If-None-Match"); inm == etag { if callback != nil { if err := callback(response, w); err != nil { w.WriteHeader(http.StatusInternalServerError) return } } w.WriteHeader(http.StatusNotModified) return } } w.Header().Add("Content-Type", "application/json") if callback != nil { if err := callback(response, w); err != nil { w.WriteHeader(http.StatusInternalServerError) return } } w.WriteHeader(http.StatusOK) w.Write(data) // nolint } r.HandleFunc("/ocs/v2.php/cloud/capabilities", handleCapabilitiesFunc) return u, capabilities } func NewCapabilitiesForTest(t *testing.T) (*url.URL, *Capabilities) { return NewCapabilitiesForTestWithCallback(t, nil) } func SetCapabilitiesGetNow(t *testing.T, capabilities *Capabilities, f func() time.Time) { capabilities.mu.Lock() defer capabilities.mu.Unlock() old := capabilities.getNow t.Cleanup(func() { capabilities.mu.Lock() defer capabilities.mu.Unlock() capabilities.getNow = old }) capabilities.getNow = f } func TestCapabilities(t *testing.T) { t.Parallel() CatchLogForTest(t) assert := assert.New(t) url, capabilities := NewCapabilitiesForTest(t) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() assert.True(capabilities.HasCapabilityFeature(ctx, url, "foo")) assert.False(capabilities.HasCapabilityFeature(ctx, url, "lala")) expectedString := "bar" if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.True(cached) } if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "baz"); assert.False(found, "should not have found value for \"baz\", got %s", value) { assert.True(cached) } if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "invalid"); assert.False(found, "should not have found value for \"invalid\", got %s", value) { assert.True(cached) } if value, cached, found := capabilities.GetStringConfig(ctx, url, "invalid", "foo"); assert.False(found, "should not have found value for \"baz\", got %s", value) { assert.True(cached) } expectedInt := 42 if value, cached, found := capabilities.GetIntegerConfig(ctx, url, "signaling", "baz"); assert.True(found) { assert.Equal(expectedInt, value) assert.True(cached) } if value, cached, found := capabilities.GetIntegerConfig(ctx, url, "signaling", "foo"); assert.False(found, "should not have found value for \"foo\", got %d", value) { assert.True(cached) } if value, cached, found := capabilities.GetIntegerConfig(ctx, url, "signaling", "invalid"); assert.False(found, "should not have found value for \"invalid\", got %d", value) { assert.True(cached) } if value, cached, found := capabilities.GetIntegerConfig(ctx, url, "invalid", "baz"); assert.False(found, "should not have found value for \"baz\", got %d", value) { assert.True(cached) } } func TestInvalidateCapabilities(t *testing.T) { t.Parallel() CatchLogForTest(t) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { called.Add(1) return nil }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() expectedString := "bar" if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value := called.Load() assert.EqualValues(1, value) // Invalidating will cause the capabilities to be reloaded. capabilities.InvalidateCapabilities(url) if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value = called.Load() assert.EqualValues(2, value) // Invalidating is throttled to about once per minute. capabilities.InvalidateCapabilities(url) if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.True(cached) } value = called.Load() assert.EqualValues(2, value) // At a later time, invalidating can be done again. SetCapabilitiesGetNow(t, capabilities, func() time.Time { return time.Now().Add(2 * time.Minute) }) capabilities.InvalidateCapabilities(url) if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value = called.Load() assert.EqualValues(3, value) } func TestCapabilitiesNoCache(t *testing.T) { t.Parallel() CatchLogForTest(t) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { called.Add(1) return nil }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() expectedString := "bar" if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value := called.Load() assert.EqualValues(1, value) // Capabilities are cached for some time if no "Cache-Control" header is set. if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.True(cached) } value = called.Load() assert.EqualValues(1, value) SetCapabilitiesGetNow(t, capabilities, func() time.Time { return time.Now().Add(minCapabilitiesCacheDuration) }) if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value = called.Load() assert.EqualValues(2, value) } func TestCapabilitiesShortCache(t *testing.T) { t.Parallel() CatchLogForTest(t) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { called.Add(1) return nil }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() expectedString := "bar" if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value := called.Load() assert.EqualValues(1, value) // Capabilities are cached for some time if no "Cache-Control" header is set. if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.True(cached) } value = called.Load() assert.EqualValues(1, value) // The capabilities are cached for a minumum duration. SetCapabilitiesGetNow(t, capabilities, func() time.Time { return time.Now().Add(minCapabilitiesCacheDuration / 2) }) if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.True(cached) } SetCapabilitiesGetNow(t, capabilities, func() time.Time { return time.Now().Add(minCapabilitiesCacheDuration) }) if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value = called.Load() assert.EqualValues(2, value) } func TestCapabilitiesNoCacheETag(t *testing.T) { t.Parallel() CatchLogForTest(t) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { ct := w.Header().Get("Content-Type") switch called.Add(1) { case 1: assert.NotEmpty(ct, "expected content-type on first request") case 2: assert.Empty(ct, "expected no content-type on second request") } return nil }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() expectedString := "bar" if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value := called.Load() assert.EqualValues(1, value) SetCapabilitiesGetNow(t, capabilities, func() time.Time { return time.Now().Add(minCapabilitiesCacheDuration) }) if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.True(cached) } value = called.Load() assert.EqualValues(2, value) } func TestCapabilitiesCacheNoMustRevalidate(t *testing.T) { t.Parallel() CatchLogForTest(t) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { if called.Add(1) == 2 { return errors.New("trigger error") } return nil }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() expectedString := "bar" if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value := called.Load() assert.EqualValues(1, value) SetCapabilitiesGetNow(t, capabilities, func() time.Time { return time.Now().Add(time.Minute) }) // Expired capabilities can still be used even in case of update errors if // "must-revalidate" is not set. if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.True(cached) } value = called.Load() assert.EqualValues(2, value) } func TestCapabilitiesNoCacheNoMustRevalidate(t *testing.T) { t.Parallel() CatchLogForTest(t) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { if called.Add(1) == 2 { return errors.New("trigger error") } return nil }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() expectedString := "bar" if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value := called.Load() assert.EqualValues(1, value) SetCapabilitiesGetNow(t, capabilities, func() time.Time { return time.Now().Add(minCapabilitiesCacheDuration) }) // Expired capabilities can still be used even in case of update errors if // "must-revalidate" is not set. if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.True(cached) } value = called.Load() assert.EqualValues(2, value) } func TestCapabilitiesNoCacheMustRevalidate(t *testing.T) { t.Parallel() CatchLogForTest(t) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { if called.Add(1) == 2 { return errors.New("trigger error") } return nil }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() expectedString := "bar" if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } value := called.Load() assert.EqualValues(1, value) SetCapabilitiesGetNow(t, capabilities, func() time.Time { return time.Now().Add(minCapabilitiesCacheDuration) }) // Capabilities will be cleared if "must-revalidate" is set and an error // occurs while fetching the updated data. capaValue, _, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo") assert.False(found, "should not have found value for \"foo\", got %s", capaValue) value = called.Load() assert.EqualValues(2, value) } func TestConcurrentExpired(t *testing.T) { t.Parallel() CatchLogForTest(t) assert := assert.New(t) var called atomic.Uint32 url, capabilities := NewCapabilitiesForTestWithCallback(t, func(cr *CapabilitiesResponse, w http.ResponseWriter) error { called.Add(1) return nil }) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() expectedString := "bar" if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) assert.False(cached) } count := 100 start := make(chan struct{}) var numCached atomic.Uint32 var numFetched atomic.Uint32 var finished sync.WaitGroup for i := 0; i < count; i++ { finished.Add(1) go func() { defer finished.Done() <-start if value, cached, found := capabilities.GetStringConfig(ctx, url, "signaling", "foo"); assert.True(found) { assert.Equal(expectedString, value) if cached { numCached.Add(1) } else { numFetched.Add(1) } } }() } SetCapabilitiesGetNow(t, capabilities, func() time.Time { return time.Now().Add(minCapabilitiesCacheDuration) }) close(start) finished.Wait() assert.EqualValues(2, called.Load()) assert.EqualValues(count, numFetched.Load()+numCached.Load()) assert.EqualValues(1, numFetched.Load()) assert.EqualValues(count-1, numCached.Load()) }