diff --git a/enterprise/frontend/src/embedding-sdk/config.ts b/enterprise/frontend/src/embedding-sdk/config.ts index f044d9038996c97a75ae321da60683696c1a5f76..fadb7ae907ea2caed0be24daa594671762cf39c7 100644 --- a/enterprise/frontend/src/embedding-sdk/config.ts +++ b/enterprise/frontend/src/embedding-sdk/config.ts @@ -4,5 +4,5 @@ export const EMBEDDING_SDK_PORTAL_ROOT_ELEMENT_ID = "metabase-sdk-portal-root"; export const EMBEDDING_SDK_FULL_PAGE_PORTAL_ROOT_ELEMENT_ID = "metabase-sdk-full-page-portal-root"; -export const getEmbeddingSdkVersion = () => - process.env.EMBEDDING_SDK_VERSION ?? "unknown"; +export const getEmbeddingSdkVersion = (): string | "unknown" => + (process.env.EMBEDDING_SDK_VERSION as string) ?? "unknown"; diff --git a/enterprise/frontend/src/embedding-sdk/lib/log-utils.ts b/enterprise/frontend/src/embedding-sdk/lib/log-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9b2f5e745c0fe1478eeae06df60ca3624fa58b5 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/lib/log-utils.ts @@ -0,0 +1,20 @@ +// Note: this functions need to return an array of two elements, when styling +// console.logs, the style needs to passed as second argument +// To use them, do `console.log(...bigWarningHeader("message"), "rest of the message")` + +// Note 2: why do we even need those? Because at the moment the SDK may log a +// lot of exceptions, we need to make actionable messages stand out + +export const bigWarningHeader = (message: string) => { + return [ + `%c${message}\n`, + "color: #FCF0A6; font-size: 16px; font-weight: bold;", + ]; +}; + +export const bigErrorHeader = (message: string) => { + return [ + `%c${message}\n`, + "color: #FF2222; font-size: 16px; font-weight: bold;", + ]; +}; diff --git a/enterprise/frontend/src/embedding-sdk/lib/version-utils.ts b/enterprise/frontend/src/embedding-sdk/lib/version-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..1322144671e8a80989f8cdf925b2799e3bc20b92 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/lib/version-utils.ts @@ -0,0 +1,13 @@ +import { versionToNumericComponents } from "metabase/lib/utils"; + +export const isSdkVersionCompatibleWithMetabaseVersion = ({ + mbVersion, + sdkVersion, +}: { + mbVersion: string; + sdkVersion: string; +}) => { + const mbVersionComponents = versionToNumericComponents(mbVersion); + const sdkVersionComponents = versionToNumericComponents(sdkVersion); + return mbVersionComponents?.[1] === sdkVersionComponents?.[1]; +}; diff --git a/enterprise/frontend/src/embedding-sdk/lib/version-utils.unit.spec.ts b/enterprise/frontend/src/embedding-sdk/lib/version-utils.unit.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c6214e1ac56af8d554271296329b30708c4e18a --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/lib/version-utils.unit.spec.ts @@ -0,0 +1,92 @@ +import { isSdkVersionCompatibleWithMetabaseVersion } from "./version-utils"; + +const expectCompatibility = ({ + mbVersion, + sdkVersion, + expected, +}: { + mbVersion: string; + sdkVersion: string; + expected: boolean; +}) => { + it(`expect sdk version ${sdkVersion} and mb version ${mbVersion} to be ${expected ? "compatible" : "incompatible"}`, () => { + expect( + isSdkVersionCompatibleWithMetabaseVersion({ + mbVersion, + sdkVersion, + }), + ).toBe(expected); + }); +}; + +describe('sdk version utils, naming used: "{0,1}.{major}.{minor}"', () => { + describe('should return true only if the "major" version is the same', () => { + expectCompatibility({ + mbVersion: "v0.52.10", + sdkVersion: "0.52.10", + expected: true, + }); + expectCompatibility({ + mbVersion: "v1.50.10", + sdkVersion: "0.51.10", + expected: false, + }); + expectCompatibility({ + mbVersion: "v1.50.10", + sdkVersion: "0.53.10", + expected: false, + }); + }); + + describe('should ignore "minors"', () => { + expectCompatibility({ + mbVersion: "v1.50.10", + sdkVersion: "0.50.11", + expected: true, + }); + + expectCompatibility({ + mbVersion: "v1.50.10", + sdkVersion: "0.50.9", + expected: true, + }); + }); + + describe("sdk version 0.xx.yy should be compatible both with MB 0.xx.yy (OSS) and 1.xx.yy (EE)", () => { + expectCompatibility({ + mbVersion: "v0.52.10", + sdkVersion: "0.52.10", + expected: true, + }); + + expectCompatibility({ + mbVersion: "v1.52.10", + sdkVersion: "0.52.10", + expected: true, + }); + }); + + describe("should ignore build tags like snapshot, alpha, beta, rc", () => { + for (const tag of ["snapshot", "alpha", "beta", "rc", "X-NOT-EXISTING"]) { + expectCompatibility({ + mbVersion: `v0.52.10-${tag}`, + sdkVersion: "0.52.10", + expected: true, + }); + } + }); + + describe("should handle versions of the sdk wrapped in double quotes (metabase#50014)", () => { + expectCompatibility({ + mbVersion: "v1.55.0", + sdkVersion: '"0.55.0"', + expected: true, + }); + + expectCompatibility({ + mbVersion: "v1.55.0", + sdkVersion: '"0.54.0"', + expected: false, + }); + }); +}); diff --git a/enterprise/frontend/src/embedding-sdk/store/auth.ts b/enterprise/frontend/src/embedding-sdk/store/auth.ts index 100d9ac5dc5f314f278804a28300a4e163f3f4a3..d0cd2c8b5fed62d93fc4587319fccf2643afd63b 100644 --- a/enterprise/frontend/src/embedding-sdk/store/auth.ts +++ b/enterprise/frontend/src/embedding-sdk/store/auth.ts @@ -3,7 +3,10 @@ import type { FetchRequestTokenFn, SDKConfig, } from "embedding-sdk"; +import { getEmbeddingSdkVersion } from "embedding-sdk/config"; import { getIsLocalhost } from "embedding-sdk/lib/is-localhost"; +import { bigErrorHeader, bigWarningHeader } from "embedding-sdk/lib/log-utils"; +import { isSdkVersionCompatibleWithMetabaseVersion } from "embedding-sdk/lib/version-utils"; import type { SdkStoreState } from "embedding-sdk/store/types"; import api from "metabase/lib/api"; import { createAsyncThunk } from "metabase/lib/redux"; @@ -43,6 +46,25 @@ export const initAuth = createAsyncThunk( dispatch(refreshSiteSettings({})), ]); + const mbVersion = siteSettings.payload?.version?.tag; + const sdkVersion = getEmbeddingSdkVersion(); + + if (mbVersion && sdkVersion !== "unknown") { + if ( + !isSdkVersionCompatibleWithMetabaseVersion({ + mbVersion, + sdkVersion, + }) + ) { + console.warn( + ...bigWarningHeader("Detected SDK compatibility issue"), + `SDK version ${sdkVersion} is not compatible with MB version ${mbVersion}, this might cause issues.`, + // eslint-disable-next-line no-unconditional-metabase-links-render -- console log in case of issues + "Learn more at https://www.metabase.com/docs/latest/embedding/sdk/version", + ); + } + } + if (!user.payload) { // The refresh user thunk just returns null if it fails to fetch the user, it doesn't throw const error = new Error( @@ -113,11 +135,7 @@ export const refreshTokenAsync = createAsyncThunk( // The host app may have a lot of logs (and the sdk logs a lot too), so we // make a big red error message to make it visible as this is 90% a blocking error - console.error( - "%cFailed to get auth session\n", - "color: #FF2222; font-size: 16px; font-weight: bold;", - exception, - ); + console.error(...bigErrorHeader("Failed to get auth session"), exception); throw exception; } diff --git a/enterprise/frontend/src/embedding-sdk/test/sdk-compatibility-warning.unit.spec.tsx b/enterprise/frontend/src/embedding-sdk/test/sdk-compatibility-warning.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..679e9aa6d2a2a6f68e700441a0b272b442b31f36 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/test/sdk-compatibility-warning.unit.spec.tsx @@ -0,0 +1,116 @@ +import { render } from "@testing-library/react"; +import fetchMock from "fetch-mock"; + +import { + setupCurrentUserEndpoint, + setupPropertiesEndpoints, +} from "__support__/server-mocks"; +import { waitForLoaderToBeRemoved } from "__support__/ui"; +import { + MetabaseProvider, + defineEmbeddingSdkConfig, +} from "embedding-sdk/components/public"; +import { + createMockSettings, + createMockTokenFeatures, + createMockUser, + createMockVersion, +} from "metabase-types/api/mocks"; + +import { getEmbeddingSdkVersion } from "../config"; + +// TODO: extract this common setup to a shared util +const METABASE_INSTANCE_URL = "path:"; +const AUTH_PROVIDER_URL = "http://auth-provider/metabase-sso"; + +const defaultAuthUriConfig = defineEmbeddingSdkConfig({ + metabaseInstanceUrl: METABASE_INSTANCE_URL, + authProviderUri: AUTH_PROVIDER_URL, + fetchRequestToken: _ => + Promise.resolve({ + id: "123", + exp: Number.MAX_SAFE_INTEGER, + }), +}); + +jest.mock("../config", () => ({ + getEmbeddingSdkVersion: jest.fn(), +})); + +const setup = async ({ + sdkVersion, + mbVersion, +}: { + sdkVersion: string; + mbVersion: string; +}) => { + (getEmbeddingSdkVersion as jest.Mock).mockReturnValue(sdkVersion); + setupPropertiesEndpoints( + createMockSettings({ + "token-features": createMockTokenFeatures({ + embedding_sdk: true, + }), + version: createMockVersion({ tag: mbVersion }), + }), + ); + setupCurrentUserEndpoint(createMockUser({ id: 1 })); + + render( + <MetabaseProvider config={defaultAuthUriConfig}> + <div>Hello</div> + </MetabaseProvider>, + ); + await waitForLoaderToBeRemoved(); +}; + +let consoleWarnSpy: jest.SpyInstance; + +const getWarnMessages = (): string[] => + consoleWarnSpy.mock.calls.map(callArguments => callArguments.join(" ")); + +describe("SDK auth errors", () => { + beforeEach(() => { + fetchMock.reset(); + + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + (getEmbeddingSdkVersion as jest.Mock).mockClear(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("SDK version compatibility", () => { + it("should show a message when the SDK version is not compatible with the Metabase version", async () => { + await setup({ sdkVersion: "0.52.10", mbVersion: "v1.55.0" }); + + expect( + getWarnMessages().filter(message => + message.includes( + "SDK version 0.52.10 is not compatible with MB version v1.55.0, this might cause issues.", + ), + ), + ).toHaveLength(1); + }); + + it("should not show a warning when the SDK version is compatible with the Metabase version", async () => { + await setup({ sdkVersion: "0.55.10", mbVersion: "v1.55.1" }); + + expect( + getWarnMessages().filter(message => + message.includes("is not compatible"), + ), + ).toHaveLength(0); + }); + + it("should not show the warning when the sdk version is unknown", async () => { + await setup({ sdkVersion: "unknown", mbVersion: "v1.55.1" }); + + expect( + getWarnMessages().filter(message => + message.includes("is not compatible"), + ), + ).toHaveLength(0); + }); + }); +});