From 761e3a8cd8ffb3ded8e654b18567cc6e3322dde3 Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Thu, 23 Jan 2025 17:33:23 +0100 Subject: [PATCH] chore: add tests for authentication functions --- mittwaldv2/client_opt_auth_test.go | 91 ++++++++++++++++++++++++++++++ mittwaldv2/suite_test.go | 13 +++++ pkg/httpclient_mock/client_mock.go | 55 ++++++++++++++++++ pkg/httpclient_mock/request_opt.go | 36 ++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 mittwaldv2/client_opt_auth_test.go create mode 100644 mittwaldv2/suite_test.go create mode 100644 pkg/httpclient_mock/client_mock.go create mode 100644 pkg/httpclient_mock/request_opt.go diff --git a/mittwaldv2/client_opt_auth_test.go b/mittwaldv2/client_opt_auth_test.go new file mode 100644 index 00000000..760cb0f0 --- /dev/null +++ b/mittwaldv2/client_opt_auth_test.go @@ -0,0 +1,91 @@ +package mittwaldv2_test + +import ( + "context" + "github.com/mittwald/api-client-go/mittwaldv2" + "github.com/mittwald/api-client-go/mittwaldv2/generated/clients/user" + "github.com/mittwald/api-client-go/pkg/httpclient_mock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "net/http" + "os" +) + +var _ = Describe("Client authentication", func() { + Describe("WithAccessToken", func() { + It("should append the provided access token to all requests", func() { + ctx := context.Background() + + runner := &httpclient_mock.MockRequestRunner{} + runner.ExpectRequest(http.MethodGet, "/v2/users/self/personal-information", httpclient_mock.WithJSONResponse(map[string]any{})) + + client, err := mittwaldv2.New(ctx, mittwaldv2.WithHTTPClient(runner), mittwaldv2.WithAccessToken("FOOBAR")) + + Expect(err).NotTo(HaveOccurred()) + + _, _, err = client.User().GetOwnAccount(ctx, user.GetOwnAccountRequest{}) + + Expect(err).NotTo(HaveOccurred()) + Expect(runner.Requests).To(HaveLen(1)) + Expect(runner.Requests[0].Header.Get("X-Access-Token")).To(Equal("FOOBAR")) + }) + }) + + Describe("WithAccessTokenFromEnv", func() { + It("should retrieve the access token from the environment", func() { + Expect(os.Setenv("MITTWALD_API_TOKEN", "FOOBAR")).To(Succeed()) + + ctx := context.Background() + + runner := &httpclient_mock.MockRequestRunner{} + runner.ExpectRequest(http.MethodGet, "/v2/users/self/personal-information", httpclient_mock.WithJSONResponse(map[string]any{})) + + client, err := mittwaldv2.New(ctx, mittwaldv2.WithHTTPClient(runner), mittwaldv2.WithAccessTokenFromEnv()) + + Expect(err).NotTo(HaveOccurred()) + + _, _, err = client.User().GetOwnAccount(ctx, user.GetOwnAccountRequest{}) + + Expect(err).NotTo(HaveOccurred()) + Expect(runner.Requests).To(HaveLen(1)) + Expect(runner.Requests[0].Header.Get("X-Access-Token")).To(Equal("FOOBAR")) + }) + }) + + Describe("WithUsernamePassword", func() { + It("should retrieve the access token from an actual login", func() { + ctx := context.Background() + + runner := &httpclient_mock.MockRequestRunner{} + runner.ExpectRequest(http.MethodPost, "/v2/authenticate", httpclient_mock.WithJSONResponse(map[string]any{"token": "FOOBAR"})) + runner.ExpectRequest(http.MethodGet, "/v2/users/self/personal-information", httpclient_mock.WithJSONResponse(map[string]any{})) + + client, err := mittwaldv2.New(ctx, mittwaldv2.WithHTTPClient(runner), mittwaldv2.WithUsernamePassword("martin@foo.example", "secret")) + + Expect(err).NotTo(HaveOccurred()) + + _, _, err = client.User().GetOwnAccount(ctx, user.GetOwnAccountRequest{}) + + Expect(err).NotTo(HaveOccurred()) + Expect(runner.Requests).To(HaveLen(2)) + Expect(runner.Requests[0].Header.Get("X-Access-Token")).To(Equal("")) + Expect(runner.Requests[1].Header.Get("X-Access-Token")).To(Equal("FOOBAR")) + }) + + It("should return an error when 2FA is required", func() { + ctx := context.Background() + + runner := &httpclient_mock.MockRequestRunner{} + runner.ExpectRequest( + http.MethodPost, + "/v2/authenticate", + httpclient_mock.WithStatus(http.StatusAccepted), + httpclient_mock.WithJSONResponse(map[string]any{"name": "SecondFactorRequired"})) + + _, err := mittwaldv2.New(ctx, mittwaldv2.WithHTTPClient(runner), mittwaldv2.WithUsernamePassword("martin@foo.example", "secret")) + + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError("second factor required; use an API token instead")) + }) + }) +}) diff --git a/mittwaldv2/suite_test.go b/mittwaldv2/suite_test.go new file mode 100644 index 00000000..05078a5d --- /dev/null +++ b/mittwaldv2/suite_test.go @@ -0,0 +1,13 @@ +package mittwaldv2_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTypes(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "mittwaldv2 client initialization") +} diff --git a/pkg/httpclient_mock/client_mock.go b/pkg/httpclient_mock/client_mock.go new file mode 100644 index 00000000..bc753c45 --- /dev/null +++ b/pkg/httpclient_mock/client_mock.go @@ -0,0 +1,55 @@ +package httpclient_mock + +import ( + "fmt" + "io" + "net/http" + "strings" +) + +// MockRequestRunner is a helper class that implements the httpclient.RequestRunner +// interface and may be used as a mock implementation during testing. +type MockRequestRunner struct { + Requests []http.Request + Matchers map[string]func(r *http.Request) *http.Response +} + +// ExpectRequest configures the mock client to expect an HTTP request with a +// given method and path. The response that should be returned can be configured +// by providing a list of ResponseOption's. +func (m *MockRequestRunner) ExpectRequest(method, path string, opts ...ResponseOption) { + bodyReader := strings.NewReader("") + bodyReadCloser := io.NopCloser(bodyReader) + + resp := http.Response{Body: bodyReadCloser, StatusCode: 204, Status: http.StatusText(204)} + + for _, o := range opts { + o(&resp) + } + + m.ExpectRequestWithResponse(method, path, &resp) +} + +func (m *MockRequestRunner) ExpectRequestWithResponse(method, path string, resp *http.Response) { + m.ExpectRequestWithResponseFunc(method, path, func(*http.Request) *http.Response { return resp }) +} + +func (m *MockRequestRunner) ExpectRequestWithResponseFunc(method, path string, resp func(r *http.Request) *http.Response) { + if m.Matchers == nil { + m.Matchers = make(map[string]func(r *http.Request) *http.Response) + } + + key := strings.ToLower(method + "_" + path) + m.Matchers[key] = resp +} + +func (m *MockRequestRunner) Do(request *http.Request) (*http.Response, error) { + m.Requests = append(m.Requests, *request) + + key := strings.ToLower(request.Method + "_" + request.URL.Path) + if handler, ok := m.Matchers[key]; ok { + return handler(request), nil + } + + return nil, fmt.Errorf("unexpected %s request to %s", request.Method, request.URL) +} diff --git a/pkg/httpclient_mock/request_opt.go b/pkg/httpclient_mock/request_opt.go new file mode 100644 index 00000000..f3adf616 --- /dev/null +++ b/pkg/httpclient_mock/request_opt.go @@ -0,0 +1,36 @@ +package httpclient_mock + +import ( + "bytes" + "encoding/json" + "io" + "net/http" +) + +type ResponseOption func(*http.Response) + +func WithStatus(status int) ResponseOption { + return func(resp *http.Response) { + resp.StatusCode = status + resp.Status = http.StatusText(status) + } +} + +func WithJSONResponse(body any) ResponseOption { + return func(resp *http.Response) { + j, _ := json.Marshal(body) + + jsonReader := bytes.NewReader(j) + jsonReadCloser := io.NopCloser(jsonReader) + + resp.Body = jsonReadCloser + if resp.Header == nil { + resp.Header = make(http.Header) + } + resp.Header.Set("Content-Type", "application/json") + + if resp.StatusCode == 0 { + WithStatus(200)(resp) + } + } +}