From d7e56b26412469c0dcbd0ef8ada19c84bc82431e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nicol=C3=B2=20Pretto?= <info@npretto.com>
Date: Fri, 25 Oct 2024 14:13:46 +0200
Subject: [PATCH] test(sdk): add tests for auth flow (#49108)

* test(sdk): add tests for auth flow

* make the fetch token fn return something that will pass the validations
we'll add in the next project

* waitForRequest helper + add expects for card endpoints on api key test
---
 .../test/auth-flow.unit.spec.tsx              | 158 ++++++++++++++++++
 frontend/test/__support__/utils.ts            |  27 ++-
 2 files changed, 184 insertions(+), 1 deletion(-)
 create mode 100644 enterprise/frontend/src/embedding-sdk/test/auth-flow.unit.spec.tsx

diff --git a/enterprise/frontend/src/embedding-sdk/test/auth-flow.unit.spec.tsx b/enterprise/frontend/src/embedding-sdk/test/auth-flow.unit.spec.tsx
new file mode 100644
index 00000000000..11d3b0f8a71
--- /dev/null
+++ b/enterprise/frontend/src/embedding-sdk/test/auth-flow.unit.spec.tsx
@@ -0,0 +1,158 @@
+import { render } from "@testing-library/react";
+import fetchMock from "fetch-mock";
+
+import {
+  setupCardEndpoints,
+  setupCardQueryEndpoints,
+  setupCurrentUserEndpoint,
+  setupPropertiesEndpoints,
+} from "__support__/server-mocks";
+import { waitForRequest } from "__support__/utils";
+import {
+  MetabaseProvider,
+  StaticQuestion,
+  defineEmbeddingSdkConfig,
+} from "embedding-sdk/components/public";
+import type { SDKConfig } from "embedding-sdk/types";
+import {
+  createMockCard,
+  createMockSettings,
+  createMockTokenFeatures,
+  createMockUser,
+} from "metabase-types/api/mocks";
+
+const METABASE_INSTANCE_URL = "path:"; // "path:" is used by our core app support server mocks
+const AUTH_PROVIDER_URL = "http://auth-provider:3000/sso/metabase";
+const MOCK_API_KEY = "mock-api-key";
+const USER_CURRENT_URL = `${METABASE_INSTANCE_URL}/api/user/current`;
+const MOCK_SESSION = {
+  exp: 1729761473,
+  iat: 1729760873,
+  id: "5e03fb0a-2398-423f-98b3-0c4abaf0c47a",
+};
+
+const MOCK_CARD = createMockCard({ id: 1 });
+
+const setup = (sdkConfig: SDKConfig) => {
+  return render(
+    <MetabaseProvider
+      config={{
+        ...sdkConfig,
+      }}
+    >
+      <StaticQuestion questionId={1} />
+    </MetabaseProvider>,
+  );
+};
+
+const getLastUserApiCall = () => fetchMock.lastCall(USER_CURRENT_URL);
+const getLastAuthProviderApiCall = () => fetchMock.lastCall(AUTH_PROVIDER_URL);
+const getLastCardQueryApiCall = () =>
+  fetchMock.lastCall(`${METABASE_INSTANCE_URL}/api/card/${MOCK_CARD.id}/query`);
+
+describe("SDK auth flow", () => {
+  beforeEach(() => {
+    fetchMock.reset();
+    fetchMock.get(AUTH_PROVIDER_URL, {
+      status: 200,
+      body: MOCK_SESSION,
+    });
+
+    setupPropertiesEndpoints(
+      createMockSettings({
+        "token-features": createMockTokenFeatures({
+          embedding_sdk: true,
+        }),
+      }),
+    );
+
+    setupCurrentUserEndpoint(createMockUser({ id: 1 }));
+
+    setupCardEndpoints(MOCK_CARD);
+    setupCardQueryEndpoints(MOCK_CARD, {} as any);
+  });
+
+  describe("when using jwtProvider", () => {
+    it("should retrieve the session from the jwtProvider and send it as 'X-Metabase-Session' header", async () => {
+      const sdkConfig = defineEmbeddingSdkConfig({
+        metabaseInstanceUrl: METABASE_INSTANCE_URL,
+        jwtProviderUri: AUTH_PROVIDER_URL,
+      });
+
+      setup(sdkConfig);
+
+      await waitForRequest(() => getLastAuthProviderApiCall());
+      expect(getLastAuthProviderApiCall()![1]).toMatchObject({
+        credentials: "include",
+        method: "GET",
+      });
+
+      await waitForRequest(() => getLastUserApiCall());
+      expect(getLastUserApiCall()![1]).toMatchObject({
+        headers: { "X-Metabase-Session": [MOCK_SESSION.id] },
+      });
+
+      await waitForRequest(() => getLastCardQueryApiCall());
+      expect(getLastCardQueryApiCall()![1]).toMatchObject({
+        headers: { "X-Metabase-Session": [MOCK_SESSION.id] },
+      });
+    });
+
+    it("should use `fetchRequestToken` if provided", async () => {
+      const customFetchFunction = jest.fn().mockImplementation(() => ({
+        ...MOCK_SESSION,
+        id: "mock-id-from-custom-fetch-function",
+      }));
+
+      const sdkConfig = defineEmbeddingSdkConfig({
+        metabaseInstanceUrl: METABASE_INSTANCE_URL,
+        jwtProviderUri: AUTH_PROVIDER_URL,
+        fetchRequestToken: customFetchFunction,
+      });
+
+      setup(sdkConfig);
+
+      expect(customFetchFunction).toHaveBeenCalledWith(AUTH_PROVIDER_URL);
+
+      await waitForRequest(() => getLastUserApiCall());
+      expect(getLastUserApiCall()![1]).toMatchObject({
+        headers: {
+          "X-Metabase-Session": ["mock-id-from-custom-fetch-function"],
+        },
+      });
+
+      await waitForRequest(() => getLastCardQueryApiCall());
+      expect(getLastCardQueryApiCall()![1]).toMatchObject({
+        headers: {
+          "X-Metabase-Session": ["mock-id-from-custom-fetch-function"],
+        },
+      });
+    });
+  });
+
+  describe("when using apiKeyProvider", () => {
+    it("should send the api key as 'X-Api-Key' header", async () => {
+      const sdkConfig = defineEmbeddingSdkConfig({
+        metabaseInstanceUrl: METABASE_INSTANCE_URL,
+        apiKey: MOCK_API_KEY,
+      });
+
+      setup(sdkConfig);
+
+      await waitForRequest(() => getLastUserApiCall());
+      expect(getLastUserApiCall()![1]).toMatchObject({
+        headers: { "X-Api-Key": [MOCK_API_KEY] },
+      });
+
+      await waitForRequest(() => getLastCardQueryApiCall());
+      expect(getLastCardQueryApiCall()![1]).toMatchObject({
+        headers: { "X-Api-Key": [MOCK_API_KEY] },
+      });
+
+      await waitForRequest(() => getLastCardQueryApiCall());
+      expect(getLastCardQueryApiCall()![1]).toMatchObject({
+        headers: { "X-Api-Key": [MOCK_API_KEY] },
+      });
+    });
+  });
+});
diff --git a/frontend/test/__support__/utils.ts b/frontend/test/__support__/utils.ts
index aee8a258683..9222d9a5e4e 100644
--- a/frontend/test/__support__/utils.ts
+++ b/frontend/test/__support__/utils.ts
@@ -1,4 +1,6 @@
-import { act } from "./ui";
+import type fetchMock from "fetch-mock";
+
+import { act, waitFor } from "./ui";
 
 export const getNextId = (() => {
   let id = 0;
@@ -10,3 +12,26 @@ export async function delay(duration: number) {
     await new Promise(resolve => setTimeout(resolve, duration));
   });
 }
+
+/** Waits for a request to have been made. It's useful to wait for a request and
+ * then check its details separately, to make tests less flaky and to have
+ * better failure messages (request arrived but wrong details vs request never
+ * arrived)
+ */
+export const waitForRequest = async (
+  requestFn: () => fetchMock.MockCall | undefined,
+) => {
+  try {
+    // try catch to make jest show the line where waitForRequest was originally called
+    await waitFor(() => {
+      if (!requestFn()) {
+        throw new Error("Request not found");
+      }
+    });
+  } catch (error) {
+    if (error instanceof Error) {
+      Error.captureStackTrace(error, waitForRequest);
+    }
+    throw error;
+  }
+};
-- 
GitLab