diff --git a/go.mod b/go.mod index 034ba97b..8c49c0ec 100644 --- a/go.mod +++ b/go.mod @@ -12,10 +12,8 @@ require ( github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect github.com/mitchellh/mapstructure v1.3.0 // indirect github.com/pelletier/go-toml v1.7.0 // indirect - github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect github.com/prometheus/client_golang v1.6.0 github.com/prometheus/client_model v0.2.0 - github.com/sasha-s/go-deadlock v0.2.0 github.com/sirupsen/logrus v1.5.0 github.com/spf13/afero v1.2.2 // indirect github.com/spf13/cast v1.3.1 // indirect diff --git a/go.sum b/go.sum index 4aca7d3b..c0db1b30 100644 --- a/go.sum +++ b/go.sum @@ -120,8 +120,6 @@ github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= -github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= -github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -149,8 +147,6 @@ github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4 github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sasha-s/go-deadlock v0.2.0 h1:lMqc+fUb7RrFS3gQLtoQsJ7/6TV/pAIFvBsqX73DK8Y= -github.com/sasha-s/go-deadlock v0.2.0/go.mod h1:StQn567HiB1fF2yJ44N9au7wOhrPS3iZqiDbRupzT10= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= diff --git a/hub/authorization.go b/hub/authorization.go index 4d641ddb..e8735a3c 100644 --- a/hub/authorization.go +++ b/hub/authorization.go @@ -20,32 +20,39 @@ type claims struct { } type mercureClaim struct { - Publish []string `json:"publish"` - Subscribe []string `json:"subscribe"` + Publish []string `json:"publish"` + Subscribe []string `json:"subscribe"` + Payload interface{} `json:"payload"` } type role int const ( - subscriberRole role = iota - publisherRole + roleSubscriber role = iota + rolePublisher ) var ( + // ErrInvalidAuthorizationHeader is returned when the Authorization header is invalid ErrInvalidAuthorizationHeader = errors.New(`invalid "Authorization" HTTP header`) - ErrNoOrigin = errors.New(`an "Origin" or a "Referer" HTTP header must be present to use the cookie-based authorization mechanism`) - ErrOriginNotAllowed = errors.New("origin not allowed to post updates") - ErrUnexpectedSigningMethod = errors.New("unexpected signing method") - ErrInvalidJWT = errors.New("invalid JWT") - ErrPublicKey = errors.New("public key error") + // ErrNoOrigin is returned when the cookie authorization mechanism is used and no Origin nor Referer headers are presents + ErrNoOrigin = errors.New(`an "Origin" or a "Referer" HTTP header must be present to use the cookie-based authorization mechanism`) + // ErrOriginNotAllowed is returned when the Origin is not allowed to post updates + ErrOriginNotAllowed = errors.New("origin not allowed to post updates") + // ErrUnexpectedSigningMethod is returned when the signing JWT method is not supported + ErrUnexpectedSigningMethod = errors.New("unexpected signing method") + // ErrInvalidJWT is returned when the JWT is invalid + ErrInvalidJWT = errors.New("invalid JWT") + // ErrPublicKey is returned when there is an error with the public key + ErrPublicKey = errors.New("public key error") ) func (h *Hub) getJWTKey(r role) []byte { var configKey string switch r { - case subscriberRole: + case roleSubscriber: configKey = "subscriber_jwt_key" - case publisherRole: + case rolePublisher: configKey = "publisher_jwt_key" } @@ -63,9 +70,9 @@ func (h *Hub) getJWTKey(r role) []byte { func (h *Hub) getJWTAlgorithm(r role) jwt.SigningMethod { var configKey string switch r { - case subscriberRole: + case roleSubscriber: configKey = "subscriber_jwt_algorithm" - case publisherRole: + case rolePublisher: configKey = "publisher_jwt_algorithm" } diff --git a/hub/authorization_test.go b/hub/authorization_test.go index acb56136..f88e6648 100644 --- a/hub/authorization_test.go +++ b/hub/authorization_test.go @@ -375,7 +375,7 @@ func TestGetJWTKeyInvalid(t *testing.T) { h.config.Set("subscriber_jwt_key", "") assert.PanicsWithValue(t, "one of these configuration parameters must be defined: [subscriber_jwt_key jwt_key]", func() { - h.getJWTKey(subscriberRole) + h.getJWTKey(roleSubscriber) }) } @@ -390,6 +390,6 @@ func TestGetJWTAlgorithmInvalid(t *testing.T) { h.config.Set("subscriber_jwt_algorithm", "foo") assert.PanicsWithValue(t, "invalid signing method: foo", func() { - h.getJWTAlgorithm(subscriberRole) + h.getJWTAlgorithm(roleSubscriber) }) } diff --git a/hub/config.go b/hub/config.go index f9f19991..296f4d5f 100644 --- a/hub/config.go +++ b/hub/config.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/viper" ) +// ErrInvalidConfig is returned when the configuration is invalid var ErrInvalidConfig = errors.New("invalid config") // SetConfigDefaults sets defaults on a Viper instance. diff --git a/hub/hub_test.go b/hub/hub_test.go index 1948d77c..742a238a 100644 --- a/hub/hub_test.go +++ b/hub/hub_test.go @@ -92,11 +92,21 @@ func createDummyAuthorizedJWT(h *Hub, r role, topicSelectors []string) string { key := h.getJWTKey(r) switch r { - case publisherRole: + case rolePublisher: token.Claims = &claims{mercureClaim{Publish: topicSelectors}, jwt.StandardClaims{}} - case subscriberRole: - token.Claims = &claims{mercureClaim{Subscribe: topicSelectors}, jwt.StandardClaims{}} + case roleSubscriber: + var payload struct { + Foo string `json:"foo"` + } + payload.Foo = "bar" + token.Claims = &claims{ + mercureClaim{ + Subscribe: topicSelectors, + Payload: payload, + }, + jwt.StandardClaims{}, + } } tokenString, _ := token.SignedString(key) diff --git a/hub/publish_test.go b/hub/publish_test.go index 6cad3d7d..ac5998e0 100644 --- a/hub/publish_test.go +++ b/hub/publish_test.go @@ -62,7 +62,7 @@ func TestPublishBadContentType(t *testing.T) { hub := createDummy() req := httptest.NewRequest("POST", defaultHubURL, nil) - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, publisherRole, []string{})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, rolePublisher, []string{})) req.Header.Add("Content-Type", "text/plain; boundary=") w := httptest.NewRecorder() hub.PublishHandler(w, req) @@ -77,7 +77,7 @@ func TestPublishNoTopic(t *testing.T) { hub := createDummy() req := httptest.NewRequest("POST", defaultHubURL, nil) - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, publisherRole, []string{})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, rolePublisher, []string{})) w := httptest.NewRecorder() hub.PublishHandler(w, req) @@ -96,7 +96,7 @@ func TestPublishNoData(t *testing.T) { req := httptest.NewRequest("POST", defaultHubURL, strings.NewReader(form.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, publisherRole, []string{"*"})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, rolePublisher, []string{"*"})) w := httptest.NewRecorder() hub.PublishHandler(w, req) @@ -117,7 +117,7 @@ func TestPublishInvalidRetry(t *testing.T) { req := httptest.NewRequest("POST", defaultHubURL, strings.NewReader(form.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, publisherRole, []string{})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, rolePublisher, []string{})) w := httptest.NewRecorder() hub.PublishHandler(w, req) @@ -139,7 +139,7 @@ func TestPublishNotAuthorizedTopicSelector(t *testing.T) { req := httptest.NewRequest("POST", defaultHubURL, strings.NewReader(form.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, publisherRole, []string{"foo"})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, rolePublisher, []string{"foo"})) w := httptest.NewRecorder() hub.PublishHandler(w, req) @@ -183,7 +183,7 @@ func TestPublishOK(t *testing.T) { req := httptest.NewRequest("POST", defaultHubURL, strings.NewReader(form.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, publisherRole, s.Topics)) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, rolePublisher, s.Topics)) w := httptest.NewRecorder() hub.PublishHandler(w, req) @@ -225,7 +225,7 @@ func TestPublishGenerateUUID(t *testing.T) { req := httptest.NewRequest("POST", defaultHubURL, strings.NewReader(form.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(h, publisherRole, []string{})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(h, rolePublisher, []string{})) w := httptest.NewRecorder() h.PublishHandler(w, req) @@ -261,7 +261,7 @@ func TestPublishWithErrorInTransport(t *testing.T) { req := httptest.NewRequest("POST", defaultHubURL, strings.NewReader(form.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, publisherRole, []string{"foo", "http://example.com/books/1"})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(hub, rolePublisher, []string{"foo", "http://example.com/books/1"})) w := httptest.NewRecorder() hub.PublishHandler(w, req) diff --git a/hub/server_test.go b/hub/server_test.go index df37ac0d..a3ef7d13 100644 --- a/hub/server_test.go +++ b/hub/server_test.go @@ -43,7 +43,7 @@ func TestForwardedHeaders(t *testing.T) { req, _ := http.NewRequest("POST", testURL, strings.NewReader(body.Encode())) req.Header.Add("X-Forwarded-For", "192.0.2.1") req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(h, publisherRole, []string{})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(h, rolePublisher, []string{})) resp2, err := client.Do(req) require.Nil(t, err) @@ -159,7 +159,7 @@ func TestServe(t *testing.T) { body := url.Values{"topic": {"http://example.com/foo/1", "http://example.com/alt/1"}, "data": {"hello"}, "id": {"first"}} req, _ := http.NewRequest("POST", testURL, strings.NewReader(body.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(h, publisherRole, []string{})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(h, rolePublisher, []string{})) resp2, err := client.Do(req) require.Nil(t, err) @@ -227,7 +227,7 @@ func TestClientClosesThenReconnects(t *testing.T) { req, err := http.NewRequest("POST", testURL, strings.NewReader(body.Encode())) require.Nil(t, err) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(h, publisherRole, []string{})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(h, rolePublisher, []string{})) resp, err := client.Do(req) require.Nil(t, err) @@ -437,7 +437,7 @@ func (s *testServer) newSubscriber(topic string, keepAlive bool) { func (s *testServer) publish(body url.Values) { req, _ := http.NewRequest("POST", testURL, strings.NewReader(body.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(s.h, publisherRole, []string{})) + req.Header.Add("Authorization", "Bearer "+createDummyAuthorizedJWT(s.h, rolePublisher, []string{})) resp, err := s.client.Do(req) require.Nil(s.t, err) diff --git a/hub/subscribe.go b/hub/subscribe.go index 3cc13260..14aa62ea 100644 --- a/hub/subscribe.go +++ b/hub/subscribe.go @@ -12,11 +12,11 @@ import ( ) type subscription struct { - ID string `json:"@id"` - Type string `json:"@type"` - Topic string `json:"topic"` - Active bool `json:"active"` - mercureClaim + ID string `json:"@id"` + Type string `json:"@type"` + Topic string `json:"topic"` + Active bool `json:"active"` + Payload interface{} `json:"payload,omitempty"` } // SubscribeHandler creates a keep alive connection and sends the events to the subscribers. @@ -200,14 +200,8 @@ func (h *Hub) dispatchSubscriptionUpdate(s *Subscriber, active bool) { Active: active, } - if s.Claims != nil { - connection.mercureClaim = s.Claims.Mercure - } - if s.Claims == nil || connection.mercureClaim.Publish == nil { - connection.mercureClaim.Publish = []string{} - } - if s.Claims == nil || connection.mercureClaim.Subscribe == nil { - connection.mercureClaim.Subscribe = []string{} + if s.Claims != nil && s.Claims.Mercure.Payload != nil { + connection.Payload = s.Claims.Mercure.Payload } json, err := json.MarshalIndent(connection, "", " ") diff --git a/hub/subscribe_test.go b/hub/subscribe_test.go index 05ebc8e3..01f237b6 100644 --- a/hub/subscribe_test.go +++ b/hub/subscribe_test.go @@ -33,6 +33,7 @@ func (m *responseWriterMock) WriteHeader(statusCode int) { } type responseTester struct { + header http.Header body string expectedStatusCode int expectedBody string @@ -41,7 +42,11 @@ type responseTester struct { } func (rt *responseTester) Header() http.Header { - return http.Header{} + if rt.header == nil { + return http.Header{} + } + + return rt.header } func (rt *responseTester) Write(buf []byte) (int, error) { @@ -362,10 +367,10 @@ func TestSubscriptionEvents(t *testing.T) { assert.Contains(t, bodyContent, `/https%3A%2F%2Fexample.com`) assert.Contains(t, bodyContent, `data: "@type": "https://mercure.rocks/Subscription",`) assert.Contains(t, bodyContent, `data: "topic": "https://example.com",`) - assert.Contains(t, bodyContent, `data: "publish": [],`) - assert.Contains(t, bodyContent, `data: "subscribe": []`) assert.Contains(t, bodyContent, `data: "active": true,`) assert.Contains(t, bodyContent, `data: "active": false,`) + assert.Contains(t, bodyContent, `data: "payload": {`) + assert.Contains(t, bodyContent, `data: "foo": "bar"`) }() go func() { @@ -525,6 +530,73 @@ func TestSendMissedEvents(t *testing.T) { hub.Stop() } +func TestSendAllEvents(t *testing.T) { + u, _ := url.Parse("bolt://test.db") + transport, _ := NewBoltTransport(u) + defer transport.Close() + defer os.Remove("test.db") + + hub := createDummyWithTransportAndConfig(transport, viper.New()) + + transport.Dispatch(&Update{ + Topics: []string{"http://example.com/foos/a"}, + Event: Event{ + ID: "a", + Data: "d1", + }, + }) + transport.Dispatch(&Update{ + Topics: []string{"http://example.com/foos/b"}, + Event: Event{ + ID: "b", + Data: "d2", + }, + }) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + + ctx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest("GET", defaultHubURL+"?topic=http://example.com/foos/{id}&Last-Event-ID=-1", nil).WithContext(ctx) + + w := &responseTester{ + header: http.Header{}, + expectedStatusCode: http.StatusOK, + expectedBody: "id: a\ndata: d1\n\nid: b\ndata: d2\n\n", + t: t, + cancel: cancel, + } + + hub.SubscribeHandler(w, req) + assert.Equal(t, "-1", w.Header().Get("Last-Event-ID")) + }() + + go func() { + defer wg.Done() + + ctx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest("GET", defaultHubURL+"?topic=http://example.com/foos/{id}", nil).WithContext(ctx) + req.Header.Add("Last-Event-ID", "-1") + + w := &responseTester{ + header: http.Header{}, + expectedStatusCode: http.StatusOK, + expectedBody: "id: a\ndata: d1\n\nid: b\ndata: d2\n\n", + t: t, + cancel: cancel, + } + + hub.SubscribeHandler(w, req) + assert.Equal(t, "-1", w.Header().Get("Last-Event-ID")) + }() + + wg.Wait() + hub.Stop() +} + func TestSubscribeHeartbeat(t *testing.T) { hub := createAnonymousDummy() hub.config.Set("heartbeat_interval", 5*time.Millisecond) diff --git a/hub/topic_selector.go b/hub/topic_selector.go index 83ec98ee..3e000814 100644 --- a/hub/topic_selector.go +++ b/hub/topic_selector.go @@ -8,7 +8,7 @@ import ( ) type selector struct { - sync.Mutex + sync.RWMutex // counter stores the number of subsribers currently using this topic counter uint32 // the uritemplate.Template instance, of nil if it's a raw string @@ -18,7 +18,7 @@ type selector struct { // topicSelectorStore caches uritemplate.Template to improve memory and CPU usage. type topicSelectorStore struct { - sync.Mutex + sync.RWMutex m map[string]*selector } @@ -27,35 +27,47 @@ func newTopicSelectorStore() *topicSelectorStore { } func (tss *topicSelectorStore) match(topic, topicSelector string, addToCache bool) bool { - // Always do an exact matching comparision first + // Always do an exact matching comparison first // Also check if the topic selector is the reserved keyword * if topicSelector == "*" || topic == topicSelector { return true } templateStore := tss.getTemplateStore(topicSelector, addToCache) - templateStore.Lock() - defer templateStore.Unlock() - if match, ok := templateStore.matchCache[topic]; ok { + templateStore.RLock() + match, ok := templateStore.matchCache[topic] + templateStore.RUnlock() + if ok { return match } - match := templateStore.template != nil && templateStore.template.Match(topic) != nil + match = templateStore.template != nil && templateStore.template.Match(topic) != nil + templateStore.Lock() templateStore.matchCache[topic] = match + templateStore.Unlock() return match } // getTemplateStore retrieves or creates the uritemplate.Template associated with this topic, or nil if it's not a template. -func (tss *topicSelectorStore) getTemplateStore(topicSelector string, addToCache bool) (s *selector) { - tss.Lock() - defer tss.Unlock() - if store, ok := tss.m[topicSelector]; ok { +func (tss *topicSelectorStore) getTemplateStore(topicSelector string, addToCache bool) *selector { + if addToCache { + tss.Lock() + defer tss.Unlock() + } else { + tss.RLock() + } + + s, ok := tss.m[topicSelector] + if !addToCache { + tss.RUnlock() + } + if ok { if addToCache { - store.counter++ + s.counter++ } - return store + return s } s = &selector{matchCache: make(map[string]bool)} @@ -76,11 +88,12 @@ func (tss *topicSelectorStore) cleanup(topics []string) { defer tss.Unlock() for _, topic := range topics { if tc, ok := tss.m[topic]; ok { - if tc.counter <= 0 { + if tc.counter == 0 { delete(tss.m, topic) - } else { - tc.counter-- + continue } + + tc.counter-- } } } diff --git a/hub/topic_selector_test.go b/hub/topic_selector_test.go index e206bf49..8ebbc53b 100644 --- a/hub/topic_selector_test.go +++ b/hub/topic_selector_test.go @@ -1,7 +1,32 @@ package hub -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestMatch(t *testing.T) { - // TODO + tss := newTopicSelectorStore() + + assert.True(t, tss.match("https://example.com/foo/bar", "https://example.com/{foo}/bar", false)) + assert.Empty(t, tss.m) + assert.True(t, tss.match("https://example.com/foo/bar", "https://example.com/{foo}/bar", true)) + assert.False(t, tss.match("https://example.com/foo/bar/baz", "https://example.com/{foo}/bar", true)) + assert.NotNil(t, tss.m["https://example.com/{foo}/bar"].template) + assert.True(t, tss.m["https://example.com/{foo}/bar"].matchCache["https://example.com/foo/bar"]) + assert.False(t, tss.m["https://example.com/{foo}/bar"].matchCache["https://example.com/foo/bar/baz"]) + assert.Equal(t, tss.m["https://example.com/{foo}/bar"].counter, uint32(1)) + + assert.True(t, tss.match("https://example.com/kevin/dunglas", "https://example.com/{fistname}/{lastname}", true)) + assert.True(t, tss.match("https://example.com/foo/bar", "*", true)) + assert.True(t, tss.match("https://example.com/foo/bar", "https://example.com/foo/bar", true)) + assert.True(t, tss.match("foo", "foo", true)) + assert.False(t, tss.match("foo", "bar", true)) + + tss.cleanup([]string{"https://example.com/{foo}/bar", "https://example.com/{fistname}/{lastname}", "bar"}) + assert.Len(t, tss.m, 1) + + tss.cleanup([]string{"https://example.com/{foo}/bar", "https://example.com/{fistname}/{lastname}"}) + assert.Empty(t, tss.m) }