diff --git a/alerts/client.go b/alerts/client.go new file mode 100644 index 0000000000..3949b75680 --- /dev/null +++ b/alerts/client.go @@ -0,0 +1,71 @@ +package alerts + +import ( + "context" + "net/http" + + "github.com/tidepool-org/platform/auth" + "github.com/tidepool-org/platform/request" +) + +// Client for managing alerts configs. +type Client struct { + client PlatformClient + token TokenProvider +} + +// NewClient builds a client for interacting with alerts API endpoints. +func NewClient(client PlatformClient, token TokenProvider) *Client { + return &Client{ + client: client, + token: token, + } +} + +// platform.Client is one implementation +type PlatformClient interface { + ConstructURL(paths ...string) string + RequestData(ctx context.Context, method string, url string, mutators []request.RequestMutator, + requestBody interface{}, responseBody interface{}, inspectors ...request.ResponseInspector) error +} + +// client.External is one implementation +type TokenProvider interface { + // ServerSessionToken provides a server-to-server API authentication token. + ServerSessionToken() (string, error) +} + +// requestWithAuth injects an auth token before calling platform.Client.RequestData. +// +// At time of writing, this is the only way to inject credentials into +// platform.Client. It might be nice to be able to use a mutator, but the auth +// is specifically handled by the platform.Client via the context field, and +// if left blank, platform.Client errors. +func (c *Client) requestWithAuth(ctx context.Context, method, url string, body any) error { + authCtx, err := c.ctxWithAuth(ctx) + if err != nil { + return err + } + return c.client.RequestData(authCtx, method, url, nil, body, nil) +} + +// Upsert updates cfg if it exists or creates it if it doesn't. +func (c *Client) Upsert(ctx context.Context, cfg *Config) error { + url := c.client.ConstructURL("v1", "alerts", cfg.UserID, cfg.FollowedUserID) + return c.requestWithAuth(ctx, http.MethodPost, url, cfg) +} + +// Delete the alerts config. +func (c *Client) Delete(ctx context.Context, cfg *Config) error { + url := c.client.ConstructURL("v1", "alerts", cfg.UserID, cfg.FollowedUserID) + return c.requestWithAuth(ctx, http.MethodDelete, url, nil) +} + +// ctxWithAuth injects a server session token into the context. +func (c *Client) ctxWithAuth(ctx context.Context) (context.Context, error) { + token, err := c.token.ServerSessionToken() + if err != nil { + return nil, err + } + return auth.NewContextWithServerSessionToken(ctx, token), nil +} diff --git a/alerts/client_test.go b/alerts/client_test.go new file mode 100644 index 0000000000..9d8e90611d --- /dev/null +++ b/alerts/client_test.go @@ -0,0 +1,112 @@ +package alerts + +import ( + "context" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/tidepool-org/platform/auth" + "github.com/tidepool-org/platform/client" + "github.com/tidepool-org/platform/log" + "github.com/tidepool-org/platform/log/null" + "github.com/tidepool-org/platform/platform" +) + +const testToken = "auth-me" + +var _ = Describe("Client", func() { + t := GinkgoT() + test404Server := testServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + }) + test200Server := testServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + testAuthServer := func(token *string) *httptest.Server { + return testServer(t, func(w http.ResponseWriter, r *http.Request) { + *token = r.Header.Get(auth.TidepoolSessionTokenHeaderKey) + w.WriteHeader(http.StatusOK) + }) + } + + Context("Delete", func() { + It("returns an error on non-200 responses", func() { + client, ctx := newAlertsClientTest(test404Server) + err := client.Delete(ctx, &Config{}) + Expect(err).Should(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("resource not found"))) + }) + + It("returns nil on success", func() { + client, ctx := newAlertsClientTest(test200Server) + err := client.Delete(ctx, &Config{}) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("injects an auth token", func() { + token := "" + client, ctx := newAlertsClientTest(testAuthServer(&token)) + _ = client.Delete(ctx, &Config{}) + Expect(token).To(Equal(testToken)) + }) + }) + + Context("Upsert", func() { + It("returns an error on non-200 responses", func() { + client, ctx := newAlertsClientTest(test404Server) + err := client.Upsert(ctx, &Config{}) + Expect(err).Should(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("resource not found"))) + }) + + It("returns nil on success", func() { + client, ctx := newAlertsClientTest(test200Server) + err := client.Upsert(ctx, &Config{}) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("injects an auth token", func() { + token := "" + client, ctx := newAlertsClientTest(testAuthServer(&token)) + _ = client.Upsert(ctx, &Config{}) + Expect(token).To(Equal(testToken)) + }) + }) +}) + +func buildTestClient(s *httptest.Server) *Client { + pCfg := &platform.Config{ + Config: &client.Config{ + Address: s.URL, + UserAgent: "foo", + }, + } + token := mockTokenProvider(testToken) + pc, err := platform.NewClient(pCfg, platform.AuthorizeAsService) + Expect(err).ToNot(HaveOccurred()) + client := NewClient(pc, token) + return client +} + +func newAlertsClientTest(server *httptest.Server) (*Client, context.Context) { + return buildTestClient(server), contextWithNullLogger() +} + +func contextWithNullLogger() context.Context { + return log.NewContextWithLogger(context.Background(), null.NewLogger()) +} + +type mockTokenProvider string + +func (p mockTokenProvider) ServerSessionToken() (string, error) { + return string(p), nil +} + +func testServer(t GinkgoTInterface, handler http.HandlerFunc) *httptest.Server { + s := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(s.Close) + return s +} diff --git a/alerts/repo.go b/alerts/repo.go deleted file mode 100644 index 3dc80686f0..0000000000 --- a/alerts/repo.go +++ /dev/null @@ -1 +0,0 @@ -package alerts diff --git a/auth/client/external.go b/auth/client/external.go index c9037cf010..8a50dc6dc0 100644 --- a/auth/client/external.go +++ b/auth/client/external.go @@ -71,9 +71,15 @@ type ExternalConfig struct { } func NewExternalConfig() *ExternalConfig { + return NewExternalConfigFromPlatform(platform.NewConfig()) +} + +const ServerSessionTokenTimeout = time.Hour + +func NewExternalConfigFromPlatform(config *platform.Config) *ExternalConfig { return &ExternalConfig{ - Config: platform.NewConfig(), - ServerSessionTokenTimeout: 3600 * time.Second, + Config: config, + ServerSessionTokenTimeout: ServerSessionTokenTimeout, } } diff --git a/platform/client.go b/platform/client.go index af0bae9ff9..aadac65d42 100644 --- a/platform/client.go +++ b/platform/client.go @@ -71,6 +71,10 @@ func (c *Client) Mutators(ctx context.Context) ([]request.RequestMutator, error) } else if serverSessionToken := auth.ServerSessionTokenFromContext(ctx); serverSessionToken != "" { authorizationMutator = NewSessionTokenHeaderMutator(serverSessionToken) } else { + // TODO: Should this really error? It might be nice to allow other + // clients the option of handling authentication on their own if + // they'd like, rather than enforcing that this method must be + // used. return nil, errors.New("service secret is missing") } } else {