diff --git a/.eslintrc b/.eslintrc index aa6d44a..20e535a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,7 +9,7 @@ "plugin:@typescript-eslint/recommended", "prettier" // prettier *has* to be the last one, to avoid conflicting rules ], - "ignorePatterns": ["pnpm-lock.yaml"], + "ignorePatterns": ["pnpm-lock.yaml", "dist"], "plugins": ["simple-import-sort", "@typescript-eslint"], "rules": { "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], diff --git a/.prettierignore b/.prettierignore index d605ec8..c50265d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,4 @@ saleor/api.tsx pnpm-lock.yaml graphql.schema.json lib/$path.ts +dist \ No newline at end of file diff --git a/package.json b/package.json index 5383eb2..dc72a86 100644 --- a/package.json +++ b/package.json @@ -104,4 +104,4 @@ "url": "https://github.com/saleor/saleor-app-sdk/issues" }, "homepage": "https://github.com/saleor/saleor-app-sdk#readme" -} \ No newline at end of file +} diff --git a/src/APL/environmentVariablesAPL.ts b/src/APL/environmentVariablesAPL.ts deleted file mode 100644 index 73119aa..0000000 --- a/src/APL/environmentVariablesAPL.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { promises as fsPromises } from "fs"; -import fetch from "node-fetch"; - -import { APL, AuthData } from "./apl"; - -interface IEnvVar { - key: string; - value: string; -} - -const ENV_FILE_NAME = ".envfile"; - -const saveDataToFile = async (variables: IEnvVar[]) => { - let currentEnvVars; - try { - await fsPromises.access(ENV_FILE_NAME); - currentEnvVars = JSON.parse(await fsPromises.readFile(ENV_FILE_NAME, "utf-8")); - } catch { - currentEnvVars = {}; - } - - await fsPromises.writeFile( - ENV_FILE_NAME, - JSON.stringify({ - ...currentEnvVars, - ...variables.reduce((acc, cur) => ({ ...acc, [cur.key]: cur.value }), {}), - }) - ); -}; - -const loadDataFromFile = async () => { - try { - await fsPromises.access(ENV_FILE_NAME); - return JSON.parse(await fsPromises.readFile(ENV_FILE_NAME, "utf-8")); - } catch { - return {}; - } -}; - -const saveDataToVercel = async (variables: IEnvVar[]) => { - await fetch(process.env.SALEOR_REGISTER_APP_URL as string, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - token: process.env.SALEOR_DEPLOYMENT_TOKEN, - envs: variables.map(({ key, value }) => ({ key, value })), - }), - }); -}; - -const loadDataFromVercel = () => process.env; - -export const getEnvVars = async () => { - if (process.env.VERCEL === "1") { - return loadDataFromVercel(); - } - return loadDataFromFile(); -}; - -export const setEnvVars = async (variables: IEnvVar[]) => { - console.debug("Setting environment variables: ", variables); - - if (process.env.VERCEL === "1") { - await saveDataToVercel(variables); - } else { - await saveDataToFile(variables); - } -}; - -export const environmentVariablesAPL: APL = { - get: async (domain) => { - const env = await getEnvVars(); - if (domain !== env.SALEOR_DOMAIN || !env.SALEOR_AUTH_TOKEN) { - return undefined; - } - return { - token: env.SALEOR_AUTH_TOKEN, - domain: env.SALEOR_DOMAIN, - }; - }, - set: async (authData: AuthData) => { - await setEnvVars([ - { - key: "SALEOR_AUTH_TOKEN", - value: authData.token, - }, - { - key: "SALEOR_DOMAIN", - value: authData.domain, - }, - ]); - }, - delete: async (domain: string) => { - const env = await getEnvVars(); - - if (domain !== env.SALEOR_DOMAIN) { - return; - } - await setEnvVars([ - { - key: "SALEOR_AUTH_TOKEN", - value: "", - }, - { - key: "SALEOR_DOMAIN", - value: "", - }, - ]); - }, - getAll: async () => { - const env = await getEnvVars(); - if (!env.SALEOR_DOMAIN || !env.SALEOR_AUTH_TOKEN) { - return []; - } - const authData = { - token: env.SALEOR_AUTH_TOKEN, - domain: env.SALEOR_DOMAIN, - }; - return [authData]; - }, -}; diff --git a/src/APL/fileAPL.test.ts b/src/APL/fileAPL.test.ts new file mode 100644 index 0000000..f4a56c1 --- /dev/null +++ b/src/APL/fileAPL.test.ts @@ -0,0 +1,184 @@ +import { promises as fsPromises } from "fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FileAPL, loadDataFromFile, saveDataToFile } from "./fileAPL"; + +describe("APL", () => { + describe("FileAPL", () => { + describe("get", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("Returns auth data for existing domain", async () => { + const stubAuthData = { + domain: "example.com", + token: "example-token", + }; + + vi.spyOn(fsPromises, "access").mockResolvedValue(); + vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData)); + + const apl = new FileAPL(); + + expect(await apl.get(stubAuthData.domain)).toStrictEqual(stubAuthData); + }); + + it("Returns undefined for unknown domain", async () => { + const stubAuthData = { + domain: "example.com", + token: "example-token", + }; + + vi.spyOn(fsPromises, "access").mockResolvedValue(); + vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData)); + + const apl = new FileAPL(); + + expect(await apl.get("unknown-domain.example.com")).toBe(undefined); + }); + }); + + describe("delete", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("Should override file when called with known domain", async () => { + const stubAuthData = { + domain: "example.com", + token: "example-token", + }; + + vi.spyOn(fsPromises, "access").mockResolvedValue(); + vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData)); + + const apl = new FileAPL(); + + expect(await apl.get(stubAuthData.domain)).toStrictEqual(stubAuthData); + }); + + it("Should not delete data when called with unknown domain", async () => { + const stubAuthData = { + domain: "example.com", + token: "example-token", + }; + + vi.spyOn(fsPromises, "access").mockResolvedValue(); + vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData)); + + const spyWriteFile = vi.spyOn(fsPromises, "writeFile").mockResolvedValue(); + + const apl = new FileAPL(); + + await apl.delete("unknown-domain.example.com"); + + expect(spyWriteFile).toBeCalledTimes(0); + }); + }); + + describe("getAll", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("Should return list with one item when auth data are existing", async () => { + const stubAuthData = { + domain: "example.com", + token: "example-token", + }; + + vi.spyOn(fsPromises, "access").mockResolvedValue(); + vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData)); + + const apl = new FileAPL(); + + expect(await apl.getAll()).toStrictEqual([stubAuthData]); + }); + + it("Should return empty list when auth data are empty", async () => { + vi.spyOn(fsPromises, "access").mockResolvedValue(); + vi.spyOn(fsPromises, "readFile").mockResolvedValue("{}"); + + const apl = new FileAPL(); + + expect(await apl.getAll()).toStrictEqual([]); + }); + }); + }); + + describe("FileAPL utils", () => { + describe("saveDataToFile", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("Save existing auth data to file", async () => { + const spyWriteFile = vi.spyOn(fsPromises, "writeFile").mockResolvedValue(); + await saveDataToFile("test.json", { domain: "example.com", token: "example-token" }); + + expect(spyWriteFile).toBeCalledWith( + "test.json", + JSON.stringify({ + domain: "example.com", + token: "example-token", + }) + ); + }); + + it("Save empty file when no auth data provided", async () => { + const spyWriteFile = vi.spyOn(fsPromises, "writeFile").mockResolvedValue(); + await saveDataToFile("test.json"); + expect(spyWriteFile).toBeCalledWith("test.json", "{}"); + }); + + it("Handle write file errors", async () => { + const spyWriteFile = vi.spyOn(fsPromises, "writeFile").mockImplementation(() => { + throw Error("Write error"); + }); + await saveDataToFile("test.json"); + expect(spyWriteFile).toBeCalledWith("test.json", "{}"); + }); + }); + + describe("loadDataFromFile", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("Load existing auth data", async () => { + vi.spyOn(fsPromises, "access").mockResolvedValue(); + const stubAuthData = { + domain: "example.com", + token: "example-token", + }; + vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData)); + expect(await loadDataFromFile("test.json")).toStrictEqual(stubAuthData); + }); + + it("Should return undefined when JSON parse fails", async () => { + vi.spyOn(fsPromises, "access").mockResolvedValue(); + vi.spyOn(fsPromises, "readFile").mockResolvedValue("Not a valid JSON"); + expect(await loadDataFromFile("test.json")).toBe(undefined); + }); + + it("Should return undefined when auth token is missing", async () => { + vi.spyOn(fsPromises, "access").mockResolvedValue(); + const stubAuthData = { + domain: "example.com", + }; + vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData)); + expect(await loadDataFromFile("test.json")).toBe(undefined); + }); + + it("Should return undefined when domain is missing", async () => { + vi.spyOn(fsPromises, "access").mockResolvedValue(); + const stubAuthData = { + token: "token", + }; + vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData)); + expect(await loadDataFromFile("test.json")).toBe(undefined); + }); + }); + }); +}); diff --git a/src/APL/fileAPL.ts b/src/APL/fileAPL.ts new file mode 100644 index 0000000..d9d162f --- /dev/null +++ b/src/APL/fileAPL.ts @@ -0,0 +1,87 @@ +import debugPkg from "debug"; +import { promises as fsPromises } from "fs"; + +import { APL, AuthData } from "./apl"; + +const debug = debugPkg.debug("FileAPL"); + +export const loadDataFromFile = async (fileName: string): Promise => { + debug(`Load auth data from the ${fileName} file`); + let parsedData: Record = {}; + try { + await fsPromises.access(fileName); + parsedData = JSON.parse(await fsPromises.readFile(fileName, "utf-8")); + } catch (err) { + debug(`Could not read auth data from the ${fileName} file`, err); + return undefined; + } + const { token, domain } = parsedData; + if (token && domain) { + return { token, domain }; + } + return undefined; +}; + +export const saveDataToFile = async (fileName: string, authData?: AuthData) => { + debug(`Save auth data to the ${fileName} file`); + const newData = authData ? JSON.stringify(authData) : "{}"; + try { + await fsPromises.writeFile(fileName, newData); + } catch (err) { + debug(`Could not save auth data to the ${fileName} file`, err); + } +}; + +export type FileAPLConfig = { + fileName?: string; +}; + +/** + * File APL + * + * The APL store auth data in the json file. + * + * Before using this APL, please take in consideration: + * - only stores single auth data entry (setting up a new one will overwrite previous values) + * - it's not recommended for production use - redeployment of the application will override + * existing values, or data persistence will not be guaranteed at all depending on chosen + * hosting solution + * + */ +export class FileAPL implements APL { + private fileName: string; + + constructor(config: FileAPLConfig = {}) { + this.fileName = config?.fileName || ".auth-data.json"; + } + + async get(domain: string) { + const authData = await loadDataFromFile(this.fileName); + if (domain === authData?.domain) { + return authData; + } + return undefined; + } + + async set(authData: AuthData) { + await saveDataToFile(this.fileName, authData); + } + + async delete(domain: string) { + const authData = await loadDataFromFile(this.fileName); + + if (domain === authData?.domain) { + await saveDataToFile(this.fileName); + } + } + + async getAll() { + const authData = await loadDataFromFile(this.fileName); + + if (!authData) { + return []; + } + + return [authData]; + } +} diff --git a/src/APL/index.ts b/src/APL/index.ts index 43122ba..6d54627 100644 --- a/src/APL/index.ts +++ b/src/APL/index.ts @@ -1,2 +1,3 @@ export * from "./apl"; -export * from "./environmentVariablesAPL"; +export * from "./fileAPL"; +export * from "./vercelAPL"; diff --git a/src/APL/vercelAPL.test.ts b/src/APL/vercelAPL.test.ts new file mode 100644 index 0000000..b4b3386 --- /dev/null +++ b/src/APL/vercelAPL.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + DOMAIN_VARIABLE_NAME, + SALEOR_DEPLOYMENT_TOKEN, + SALEOR_REGISTER_APP_URL, + TOKEN_VARIABLE_NAME, + VercelAPL, +} from "./vercelAPL"; + +describe("APL", () => { + describe("VercelAPL", () => { + describe("constructor", () => { + const initialEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...initialEnv }; + }); + + it("Raise an error when configuration is missing", async () => { + delete process.env[SALEOR_REGISTER_APP_URL]; + process.env[SALEOR_DEPLOYMENT_TOKEN] = "token"; + + expect(() => new VercelAPL()).toThrow(); + + process.env[SALEOR_REGISTER_APP_URL] = "http://example.com"; + delete process.env[SALEOR_DEPLOYMENT_TOKEN]; + + expect(() => new VercelAPL()).toThrow(); + }); + }); + + it("Constructor with config values", async () => { + expect( + () => + new VercelAPL({ + deploymentToken: "token", + registerAppURL: "http://example.com", + }) + ).not.toThrow(); + }); + + it("Constructor with config values from environment variables", async () => { + process.env[SALEOR_REGISTER_APP_URL] = "http://example.com"; + process.env[SALEOR_DEPLOYMENT_TOKEN] = "token"; + + expect(() => new VercelAPL()).not.toThrow(); + }); + + describe("get", () => { + describe("Read existing auth data from env", () => { + const initialEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...initialEnv }; + }); + + it("Read existing auth data", async () => { + const stubAuthData = { + domain: "example.com", + token: "example-token", + }; + + process.env[TOKEN_VARIABLE_NAME] = stubAuthData.token; + process.env[DOMAIN_VARIABLE_NAME] = stubAuthData.domain; + + const apl = new VercelAPL({ + deploymentToken: "token", + registerAppURL: "http://example.com", + }); + + expect(await apl.get(stubAuthData.domain)).toStrictEqual(stubAuthData); + }); + + it("Return undefined when unknown domain requested", async () => { + const stubAuthData = { + domain: "example.com", + token: "example-token", + }; + + process.env[TOKEN_VARIABLE_NAME] = stubAuthData.token; + process.env[DOMAIN_VARIABLE_NAME] = stubAuthData.domain; + + const apl = new VercelAPL({ + deploymentToken: "token", + registerAppURL: "http://example.com", + }); + + expect(await apl.get("unknown-domain.example.com")).toBe(undefined); + }); + + it("Return undefined when no data is defined", async () => { + process.env[TOKEN_VARIABLE_NAME] = undefined; + process.env[DOMAIN_VARIABLE_NAME] = undefined; + + const apl = new VercelAPL({ + deploymentToken: "token", + registerAppURL: "http://example.com", + }); + + expect(await apl.get("example.com")).toBe(undefined); + }); + }); + }); + + describe("getAll", () => { + describe("Read existing auth data from env", () => { + const initialEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...initialEnv }; + }); + + it("Read existing auth data", async () => { + const stubAuthData = { + domain: "example.com", + token: "example-token", + }; + + process.env[TOKEN_VARIABLE_NAME] = stubAuthData.token; + process.env[DOMAIN_VARIABLE_NAME] = stubAuthData.domain; + + const apl = new VercelAPL({ + deploymentToken: "token", + registerAppURL: "http://example.com", + }); + + expect(await apl.getAll()).toStrictEqual([stubAuthData]); + }); + + it("Return empty list when no auth data are existing", async () => { + process.env[TOKEN_VARIABLE_NAME] = undefined; + process.env[DOMAIN_VARIABLE_NAME] = undefined; + + const apl = new VercelAPL({ + deploymentToken: "token", + registerAppURL: "http://example.com", + }); + + expect(await apl.getAll()).toStrictEqual([]); + }); + }); + }); + }); +}); diff --git a/src/APL/vercelAPL.ts b/src/APL/vercelAPL.ts new file mode 100644 index 0000000..f354491 --- /dev/null +++ b/src/APL/vercelAPL.ts @@ -0,0 +1,116 @@ +/* eslint-disable class-methods-use-this */ +import debugPkg from "debug"; +import fetch from "node-fetch"; + +import { APL, AuthData } from "./apl"; + +const debug = debugPkg.debug("VercelAPL"); + +export const TOKEN_VARIABLE_NAME = "SALEOR_AUTH_TOKEN"; +export const DOMAIN_VARIABLE_NAME = "SALEOR_DOMAIN"; +export const SALEOR_REGISTER_APP_URL = "SALEOR_REGISTER_APP_URL"; +export const SALEOR_DEPLOYMENT_TOKEN = "SALEOR_DEPLOYMENT_TOKEN"; + +const envAuthData = (): AuthData | undefined => { + const token = process.env[TOKEN_VARIABLE_NAME]; + const domain = process.env[DOMAIN_VARIABLE_NAME]; + if (!token || !domain) { + return undefined; + } + return { + token, + domain, + }; +}; + +const saveDataToVercel = async ( + registerAppURL: string, + deploymentToken: string, + authData?: AuthData +) => { + debug("Saving data to Vercel"); + + try { + await fetch(registerAppURL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token: deploymentToken, + envs: { + [TOKEN_VARIABLE_NAME]: authData?.token || "", + [DOMAIN_VARIABLE_NAME]: authData?.domain || "", + }, + }), + }); + } catch (error) { + debug("Error during saving the data:", error); + } +}; + +export type VercelAPLConfig = { + registerAppURL?: string; + deploymentToken?: string; +}; +/** Vercel APL + * + * Use environment variables for auth data storage. To update data on existing deployment, + * theres Saleor microservice which update new values with the Vercel API and restarts the instance. + * + * This APL should be used for single tenant purposes due to it's limitations: + * - only stores single auth data entry (setting up a new one will overwrite previous values) + * - changing the environment variables require server restart + * + * With this APL we recommend using the [Saleor CLI](https://docs.saleor.io/docs/3.x/cli), + * which automatically set up the required environment variables during deployment: + * - SALEOR_REGISTER_APP_URL: the URL for microservice which set up variables using Vercel API + * - SALEOR_DEPLOYMENT_TOKEN: token for your particular Vercel deployment + */ +export class VercelAPL implements APL { + private registerAppURL: string; + + private deploymentToken: string; + + constructor(config?: VercelAPLConfig) { + const registerAppURL = config?.registerAppURL || process.env.SALEOR_REGISTER_APP_URL; + if (!registerAppURL) { + debug("Missing registerAppURL"); + throw new Error("Misconfiguration: please provide registerAppUrl"); + } + const deploymentToken = config?.deploymentToken || process.env.SALEOR_DEPLOYMENT_TOKEN; + if (!deploymentToken) { + debug("Missing deploymentToken"); + throw new Error("Misconfiguration: please provide deploymentToken"); + } + + this.registerAppURL = registerAppURL; + this.deploymentToken = deploymentToken; + } + + async get(domain: string) { + const authData = envAuthData(); + + if (authData && domain === authData?.domain) { + return authData; + } + return undefined; + } + + async set(authData: AuthData) { + await saveDataToVercel(this.registerAppURL, this.deploymentToken, authData); + } + + async delete(domain: string) { + if (domain === envAuthData()?.domain) { + // Override existing data with the empty values + await saveDataToVercel(this.registerAppURL, this.deploymentToken); + } + } + + async getAll() { + const authData = envAuthData(); + if (!authData) { + return []; + } + return [authData]; + } +}