pkg/cloudevents/client_test.go

389 lines
12 KiB
Go

/*
Copyright 2019 The Knative Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cloudevents_test
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"k8s.io/apimachinery/pkg/util/sets"
"knative.dev/pkg/cloudevents"
)
var (
// Headers that are added to the response, but we don't want to check in our assertions.
unimportantHeaders = sets.NewString(
"accept-encoding",
"content-length",
"user-agent",
)
)
func TestNewClient(t *testing.T) {
m := "NewClient"
for _, test := range []struct {
name string
eventType string
source string
target string
want *cloudevents.Client
opt cmp.Option
}{
{
name: "Simple",
want: &cloudevents.Client{},
}, {
name: "Full",
eventType: "event.type",
source: "source",
target: "target",
want: &cloudevents.Client{
Target: "target",
},
},
} {
t.Run(test.name, func(t *testing.T) {
got := cloudevents.NewClient(test.target, cloudevents.Builder{Source: test.source, EventType: test.eventType})
if diff := cmp.Diff(test.want, got, test.opt, cmpopts.IgnoreUnexported(cloudevents.Client{})); diff != "" {
t.Errorf("%s (-want, +got) = %v", m, diff)
}
})
}
}
func TestClientSend(t *testing.T) {
now := time.Now()
eventID := "AABBCCDDEE"
doc := FirestoreDocument{
Name: "projects/demo/databases/default/documents/users/inlined",
Fields: map[string]interface{}{
"project": "eventing",
"handle": "@inlined",
},
CreateTime: time.Date(1985, 6, 5, 12, 0, 0, 0, time.UTC),
UpdateTime: now.UTC(),
}
service := "firestore.googleapis.com"
for _, test := range []struct {
name string
client cloudevents.Client
override *cloudevents.V01EventContext
fakeResponse *http.Response
expectedRequest *requestValidation
errText string
}{
{
name: "binary simple v0.1",
client: func() cloudevents.Client {
client := cloudevents.NewClient(
"server url",
cloudevents.Builder{
Source: fmt.Sprintf("//%s/%s", service, doc.Name),
EventType: "google.firestore.document.create",
Encoding: cloudevents.BinaryV01,
},
)
return *client
}(),
override: &cloudevents.V01EventContext{
EventTime: now,
EventID: eventID,
},
fakeResponse: &http.Response{
StatusCode: http.StatusAccepted,
},
expectedRequest: &requestValidation{
Headers: map[string][]string{
"ce-cloudeventsversion": {"0.1"},
"ce-eventid": {"AABBCCDDEE"},
"ce-eventtime": {now.Format(time.RFC3339Nano)},
"ce-eventtype": {"google.firestore.document.create"},
"ce-source": {"//firestore.googleapis.com/projects/demo/databases/default/documents/users/inlined"},
"content-type": {"application/json"},
},
Body: fmt.Sprintf(
`{"name":"projects/demo/databases/default/documents/users/inlined","fields":{"handle":"@inlined","project":"eventing"},"createTime":"1985-06-05T12:00:00Z","updateTime":%q}`,
now.UTC().Format(time.RFC3339Nano),
),
},
}, {
name: "binary full v0.1",
client: func() cloudevents.Client {
client := cloudevents.NewClient(
"source url",
cloudevents.Builder{
Source: fmt.Sprintf("//%s/%s", service, doc.Name),
EventType: "google.firestore.document.create",
EventTypeVersion: "v1beta2",
SchemaURL: "http://type.googleapis.com/google.firestore.v1beta1.Document",
Encoding: cloudevents.BinaryV01,
Extensions: map[string]interface{}{
"purpose": "tbd",
},
},
)
return *client
}(),
override: &cloudevents.V01EventContext{
EventTime: now,
EventID: eventID,
},
fakeResponse: &http.Response{
StatusCode: http.StatusAccepted,
},
expectedRequest: &requestValidation{
Headers: map[string][]string{
"ce-cloudeventsversion": {"0.1"},
"ce-eventtypeversion": {"v1beta2"},
"ce-eventid": {"AABBCCDDEE"},
"ce-eventtime": {now.Format(time.RFC3339Nano)},
"ce-eventtype": {"google.firestore.document.create"},
"ce-source": {"//firestore.googleapis.com/projects/demo/databases/default/documents/users/inlined"},
"ce-schemaurl": {"http://type.googleapis.com/google.firestore.v1beta1.Document"},
"ce-x-purpose": {`"tbd"`},
"content-type": {"application/json"},
},
Body: fmt.Sprintf(
`{"name":"projects/demo/databases/default/documents/users/inlined","fields":{"handle":"@inlined","project":"eventing"},"createTime":"1985-06-05T12:00:00Z","updateTime":%q}`,
now.UTC().Format(time.RFC3339Nano),
),
},
}, {
name: "structured simple v0.1",
client: func() cloudevents.Client {
client := cloudevents.NewClient(
"server url",
cloudevents.Builder{
Source: fmt.Sprintf("//%s/%s", service, doc.Name),
EventType: "google.firestore.document.create",
Encoding: cloudevents.StructuredV01,
},
)
return *client
}(),
override: &cloudevents.V01EventContext{
EventTime: now,
EventID: eventID,
},
fakeResponse: &http.Response{
StatusCode: http.StatusAccepted,
},
expectedRequest: &requestValidation{
Headers: map[string][]string{
"content-type": {"application/cloudevents+json"},
},
Body: fmt.Sprintf(
`{"cloudEventsVersion":"0.1","contentType":"application/json","data":{"name":"projects/demo/databases/default/documents/users/inlined","fields":{"handle":"@inlined","project":"eventing"},"createTime":"1985-06-05T12:00:00Z","updateTime":%q},"eventID":"AABBCCDDEE","eventTime":%q,"eventType":"google.firestore.document.create","extensions":null,"source":"//firestore.googleapis.com/projects/demo/databases/default/documents/users/inlined"}`,
now.UTC().Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
),
},
}, {
name: "structured full v0.1",
client: func() cloudevents.Client {
client := cloudevents.NewClient(
"source url",
cloudevents.Builder{
Source: fmt.Sprintf("//%s/%s", service, doc.Name),
EventType: "google.firestore.document.create",
EventTypeVersion: "v1beta2",
SchemaURL: "http://type.googleapis.com/google.firestore.v1beta1.Document",
Encoding: cloudevents.StructuredV01,
Extensions: map[string]interface{}{
"purpose": "tbd",
},
},
)
return *client
}(),
override: &cloudevents.V01EventContext{
EventTime: now,
EventID: eventID,
},
fakeResponse: &http.Response{
StatusCode: http.StatusAccepted,
},
expectedRequest: &requestValidation{
Headers: map[string][]string{
"content-type": {"application/cloudevents+json"},
},
Body: fmt.Sprintf(
`{"cloudEventsVersion":"0.1","contentType":"application/json","data":{"name":"projects/demo/databases/default/documents/users/inlined","fields":{"handle":"@inlined","project":"eventing"},"createTime":"1985-06-05T12:00:00Z","updateTime":%q},"eventID":"AABBCCDDEE","eventTime":%q,"eventType":"google.firestore.document.create","eventTypeVersion":"v1beta2","extensions":{"purpose":"tbd"},"schemaURL":"http://type.googleapis.com/google.firestore.v1beta1.Document","source":"//firestore.googleapis.com/projects/demo/databases/default/documents/users/inlined"}`,
now.UTC().Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
),
},
}, {
name: "builder fails, no event type",
client: func() cloudevents.Client {
client := cloudevents.NewClient(
"server url",
cloudevents.Builder{
Source: fmt.Sprintf("//%s/%s", service, doc.Name),
},
)
return *client
}(),
override: &cloudevents.V01EventContext{},
errText: "EventType resolved empty",
}, {
name: "request not accepted",
client: func() cloudevents.Client {
client := cloudevents.NewClient(
"server url",
cloudevents.Builder{
Source: fmt.Sprintf("//%s/%s", service, doc.Name),
EventType: "google.firestore.document.create",
},
)
return *client
}(),
override: &cloudevents.V01EventContext{},
fakeResponse: &http.Response{
StatusCode: http.StatusBadRequest,
Body: ioutil.NopCloser(bytes.NewBufferString(`{"error":"nope"}`)),
},
errText: "Status[400 Bad Request]",
},
} {
t.Run(test.name, func(t *testing.T) {
handler := &fakeHandler{
t: t,
response: test.fakeResponse,
requests: make([]requestValidation, 0),
}
server := httptest.NewServer(handler)
defer server.Close()
// Need to update dynamic target.
test.client.Target = server.URL
err := test.client.Send(doc, *test.override)
if test.errText != "" {
if err == nil {
t.Fatalf("failed to return expected error, got nil")
}
want := test.errText
got := err.Error()
if !strings.Contains(got, want) {
t.Fatalf("failed to return expected error, got %q, want %q", err, want)
}
return
} else {
if err != nil {
t.Fatalf("Failed to send event %s", err)
}
}
rv := handler.popRequest(t)
assertEquality(t, server.URL, *test.expectedRequest, rv)
})
}
}
type requestValidation struct {
Host string
Headers http.Header
Body string
}
type fakeHandler struct {
t *testing.T
response *http.Response
requests []requestValidation
}
func (f *fakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
// Make a copy of the request.
body, err := ioutil.ReadAll(r.Body)
if err != nil {
f.t.Error("Failed to read the request body")
}
f.requests = append(f.requests, requestValidation{
Host: r.Host,
Headers: r.Header,
Body: string(body),
})
// Write the response.
if f.response != nil {
for h, vs := range f.response.Header {
for _, v := range vs {
w.Header().Add(h, v)
}
}
w.WriteHeader(f.response.StatusCode)
var buf bytes.Buffer
if f.response.ContentLength > 0 {
buf.ReadFrom(f.response.Body)
w.Write(buf.Bytes())
}
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte(""))
}
}
func (f *fakeHandler) popRequest(t *testing.T) requestValidation {
if len(f.requests) == 0 {
t.Error("Unable to pop request")
}
rv := f.requests[0]
f.requests = f.requests[1:]
return rv
}
func assertEquality(t *testing.T, replacementURL string, expected, actual requestValidation) {
server, err := url.Parse(replacementURL)
if err != nil {
t.Errorf("Bad replacement URL: %q", replacementURL)
}
expected.Host = server.Host
canonicalizeHeaders(expected, actual)
if diff := cmp.Diff(expected, actual); diff != "" {
t.Errorf("Unexpected difference (-want, +got): %v", diff)
}
}
func canonicalizeHeaders(rvs ...requestValidation) {
// HTTP header names are case-insensitive, so normalize them to lower case for comparison.
for _, rv := range rvs {
headers := rv.Headers
for n, v := range headers {
delete(headers, n)
ln := strings.ToLower(n)
if !unimportantHeaders.Has(ln) {
headers[ln] = v
}
}
}
}