From fda3f0932237465282bf6cd90f854276b7f7f384 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Thu, 1 Feb 2024 14:16:28 -1000 Subject: [PATCH] feat(manager): add `GitManager` (#1268) Co-authored-by: Baptiste Morelle --- .../src/managers/SliceMachineManager.ts | 4 + .../createSliceMachineManagerMiddleware.ts | 1 + .../manager/src/managers/git/GitManager.ts | 456 ++++++++++++++++++ .../src/managers/git/buildGitRepoSpecifier.ts | 24 + packages/manager/src/managers/git/types.ts | 24 + .../test/PrismicAuthManager-login.test.ts | 3 + .../test/SliceMachineManager-getState.test.ts | 1 + ...eManager-git-checkHasWriteAPIToken.test.ts | 102 ++++ ...eManager-git-createGitHubAuthState.test.ts | 45 ++ ...ineManager-git-deleteWriteAPIToken.test.ts | 80 +++ ...achineManager-git-fetchLinkedRepos.test.ts | 45 ++ ...liceMachineManager-git-fetchOwners.test.ts | 37 ++ ...SliceMachineManager-git-fetchRepos.test.ts | 58 +++ .../SliceMachineManager-git-linkRepo.test.ts | 56 +++ ...SliceMachineManager-git-unlinkRepo.test.ts | 56 +++ ...ineManager-git-updateWriteAPIToken.test.ts | 85 ++++ ...anager-project-installDependencies.test.ts | 12 - ...ger-telemetry-getExperimentVariant.test.ts | 26 +- ...liceMachineManager-telemetry-group.test.ts | 18 - ...eMachineManager-telemetry-identify.test.ts | 18 - ...liceMachineManager-telemetry-track.test.ts | 18 - packages/manager/test/__setup__.ts | 112 ++++- .../test/__testutils__/createAPIFixture.ts | 96 ++++ 23 files changed, 1285 insertions(+), 92 deletions(-) create mode 100644 packages/manager/src/managers/git/GitManager.ts create mode 100644 packages/manager/src/managers/git/buildGitRepoSpecifier.ts create mode 100644 packages/manager/src/managers/git/types.ts create mode 100644 packages/manager/test/SliceMachineManager-git-checkHasWriteAPIToken.test.ts create mode 100644 packages/manager/test/SliceMachineManager-git-createGitHubAuthState.test.ts create mode 100644 packages/manager/test/SliceMachineManager-git-deleteWriteAPIToken.test.ts create mode 100644 packages/manager/test/SliceMachineManager-git-fetchLinkedRepos.test.ts create mode 100644 packages/manager/test/SliceMachineManager-git-fetchOwners.test.ts create mode 100644 packages/manager/test/SliceMachineManager-git-fetchRepos.test.ts create mode 100644 packages/manager/test/SliceMachineManager-git-linkRepo.test.ts create mode 100644 packages/manager/test/SliceMachineManager-git-unlinkRepo.test.ts create mode 100644 packages/manager/test/SliceMachineManager-git-updateWriteAPIToken.test.ts create mode 100644 packages/manager/test/__testutils__/createAPIFixture.ts diff --git a/packages/manager/src/managers/SliceMachineManager.ts b/packages/manager/src/managers/SliceMachineManager.ts index 4c2e2d9196..491d0cb1bf 100644 --- a/packages/manager/src/managers/SliceMachineManager.ts +++ b/packages/manager/src/managers/SliceMachineManager.ts @@ -38,6 +38,7 @@ import { TelemetryManager } from "./telemetry/TelemetryManager"; import { buildPrismicRepositoryAPIEndpoint } from "../lib/buildPrismicRepositoryAPIEndpoint"; import { DocumentationManager } from "./documentation/DocumentationManager"; import { SliceTemplateLibraryManager } from "./sliceTemplateLibrary/SliceTemplateLibraryManager"; +import { GitManager } from "./git/GitManager"; type SliceMachineManagerGetStateReturnType = { env: { @@ -116,6 +117,7 @@ export class SliceMachineManager { telemetry: TelemetryManager; user: UserManager; versions: VersionsManager; + git: GitManager; constructor(args?: SliceMachineManagerConstructorArgs) { // _prismicAuthManager must be set at least before UserManager @@ -143,6 +145,8 @@ export class SliceMachineManager { this.telemetry = new TelemetryManager(this); + this.git = new GitManager(this); + this.cwd = args?.cwd ?? process.cwd(); } diff --git a/packages/manager/src/managers/createSliceMachineManagerMiddleware.ts b/packages/manager/src/managers/createSliceMachineManagerMiddleware.ts index 19fc798488..d1fa6020c2 100644 --- a/packages/manager/src/managers/createSliceMachineManagerMiddleware.ts +++ b/packages/manager/src/managers/createSliceMachineManagerMiddleware.ts @@ -38,6 +38,7 @@ const omitProcedures = defineOmits()([ "versions._sliceMachineManager", "documentation._sliceMachineManager", "sliceTemplateLibrary._sliceMachineManager", + "git._sliceMachineManager", "getSliceMachinePluginRunner", "getPrismicAuthManager", ]); diff --git a/packages/manager/src/managers/git/GitManager.ts b/packages/manager/src/managers/git/GitManager.ts new file mode 100644 index 0000000000..0858790ec3 --- /dev/null +++ b/packages/manager/src/managers/git/GitManager.ts @@ -0,0 +1,456 @@ +import * as t from "io-ts"; +import * as tt from "io-ts-types"; + +import fetch from "../../lib/fetch"; +import { decode } from "../../lib/decode"; + +import { API_ENDPOINTS } from "../../constants/API_ENDPOINTS"; + +import { + UnauthenticatedError, + UnauthorizedError, + UnexpectedDataError, +} from "../../errors"; + +import { BaseManager } from "../BaseManager"; + +import { GitRepo, GitRepoSpecifier, Owner } from "./types"; +import { buildGitRepoSpecifier } from "./buildGitRepoSpecifier"; + +type GitManagerCreateGitHubAuthStateReturnType = { + key: string; + expiresAt: Date; +}; + +type GitManagerFetchOwnersReturnType = Owner[]; + +type GitManagerFetchReposReturnType = GitRepo[]; + +type GitManagerFetchReposArgs = { + provider: "gitHub"; + owner: string; + query?: string; + page?: number; +}; + +type GitManagerFetchLinkedReposArgs = { + prismic: { + domain: string; + }; +}; + +type GitManagerFetchLinkedReposReturnType = GitRepoSpecifier[]; + +type GitManagerLinkRepoArgs = { + prismic: { + domain: string; + }; + git: { + provider: "gitHub"; + owner: string; + name: string; + }; +}; + +type GitManagerUnlinkRepoArgs = { + prismic: { + domain: string; + }; + git: { + provider: "gitHub"; + owner: string; + name: string; + }; +}; + +type CheckHasWriteAPITokenArgs = { + prismic: { + domain: string; + }; + git: { + provider: "gitHub"; + owner: string; + name: string; + }; +}; + +type UpdateWriteAPITokenArgs = { + prismic: { + domain: string; + }; + git: { + provider: "gitHub"; + owner: string; + name: string; + }; + token: string; +}; + +type DeleteWriteAPITokenArgs = { + prismic: { + domain: string; + }; + git: { + provider: "gitHub"; + owner: string; + name: string; + }; +}; + +export class GitManager extends BaseManager { + async createGitHubAuthState(): Promise { + const url = new URL( + "./git/github/create-auth-state", + API_ENDPOINTS.SliceMachineV1, + ); + const res = await this.#fetch(url, { method: "POST" }); + + if (!res.ok) { + switch (res.status) { + case 401: + throw new UnauthorizedError(); + default: + throw new Error("Failed to create GitHub auth state."); + } + } + + const json = await res.json(); + const { value, error } = decode( + t.type({ + key: t.string, + expiresAt: tt.DateFromISOString, + }), + json, + ); + + if (error) { + throw new UnexpectedDataError( + `Failed to decode GitHub auth state: ${error.errors.join(", ")}`, + { cause: error }, + ); + } + + return value; + } + + async fetchOwners(): Promise { + const url = new URL("./git/owners", API_ENDPOINTS.SliceMachineV1); + const res = await this.#fetch(url); + + if (!res.ok) { + switch (res.status) { + case 401: + throw new UnauthenticatedError(); + case 403: + throw new UnauthorizedError(); + default: + throw new Error("Failed to fetch owners."); + } + } + + const json = await res.json(); + const { value, error } = decode( + t.type({ + owners: t.array( + t.type({ + provider: t.literal("gitHub"), + id: t.string, + name: t.string, + type: t.union([t.literal("user"), t.literal("team"), t.null]), + }), + ), + }), + json, + ); + + if (error) { + throw new UnexpectedDataError( + `Failed to decode owners: ${error.errors.join(", ")}`, + { cause: error }, + ); + } + + return value.owners; + } + + async fetchRepos( + args: GitManagerFetchReposArgs, + ): Promise { + const url = new URL("./git/repos", API_ENDPOINTS.SliceMachineV1); + url.searchParams.set("provider", args.provider); + url.searchParams.set("owner", args.owner); + if (args.query) { + url.searchParams.set("q", args.query); + } + if (args.page && args.page > 0) { + url.searchParams.set("page", args.page.toString()); + } + + const res = await this.#fetch(url); + + if (!res.ok) { + switch (res.status) { + case 401: + throw new UnauthenticatedError(); + case 403: + throw new UnauthorizedError(); + default: + throw new Error("Failed to fetch repos."); + } + } + + const json = await res.json(); + const { value, error } = decode( + t.type({ + repos: t.array( + t.type({ + provider: t.literal("gitHub"), + id: t.string, + owner: t.string, + name: t.string, + url: t.string, + pushedAt: tt.DateFromISOString, + }), + ), + }), + json, + ); + + if (error) { + throw new UnexpectedDataError( + `Failed to decode repos: ${error.errors.join(", ")}`, + { cause: error }, + ); + } + + return value.repos; + } + + async fetchLinkedRepos( + args: GitManagerFetchLinkedReposArgs, + ): Promise { + const url = new URL("./git/linked-repos", API_ENDPOINTS.SliceMachineV1); + url.searchParams.set("repository", args.prismic.domain); + + const res = await this.#fetch(url); + + if (!res.ok) { + switch (res.status) { + case 401: + throw new UnauthenticatedError(); + case 403: + throw new UnauthorizedError(); + default: + throw new Error("Failed to fetch linked repos."); + } + } + + const json = await res.json(); + const { value, error } = decode( + t.type({ + repos: t.array( + t.type({ + provider: t.literal("gitHub"), + owner: t.string, + name: t.string, + }), + ), + }), + json, + ); + + if (error) { + throw new UnexpectedDataError( + `Failed to decode linked repos: ${error.errors.join(", ")}`, + { cause: error }, + ); + } + + return value.repos; + } + + async linkRepo(args: GitManagerLinkRepoArgs): Promise { + const url = new URL("./git/linked-repos", API_ENDPOINTS.SliceMachineV1); + const res = await this.#fetch(url, { + method: "PUT", + body: { + prismic: { + domain: args.prismic.domain, + }, + git: { + provider: args.git.provider, + owner: args.git.owner, + name: args.git.name, + }, + }, + }); + + if (!res.ok) { + switch (res.status) { + case 401: + throw new UnauthenticatedError(); + case 403: + throw new UnauthorizedError(); + default: + throw new Error("Failed to link repos."); + } + } + } + + async unlinkRepo(args: GitManagerUnlinkRepoArgs): Promise { + const url = new URL("./git/linked-repos", API_ENDPOINTS.SliceMachineV1); + const res = await this.#fetch(url, { + method: "DELETE", + body: { + prismic: { + domain: args.prismic.domain, + }, + git: { + provider: args.git.provider, + owner: args.git.owner, + name: args.git.name, + }, + }, + }); + + if (!res.ok) { + switch (res.status) { + case 401: + throw new UnauthenticatedError(); + case 403: + throw new UnauthorizedError(); + default: + throw new Error("Failed to unlink repos."); + } + } + } + + async checkHasWriteAPIToken( + args: CheckHasWriteAPITokenArgs, + ): Promise { + const url = new URL( + "./git/linked-repos/write-api-token", + API_ENDPOINTS.SliceMachineV1, + ); + url.searchParams.set("repository", args.prismic.domain); + url.searchParams.set( + "git", + buildGitRepoSpecifier({ + provider: args.git.provider, + owner: args.git.owner, + name: args.git.name, + }), + ); + + const res = await this.#fetch(url); + + if (!res.ok) { + switch (res.status) { + case 401: + throw new UnauthenticatedError(); + case 403: + throw new UnauthorizedError(); + default: + throw new Error("Failed to check Prismic Write API token."); + } + } + + const json = await res.json(); + const { value, error } = decode( + t.type({ + hasWriteAPIToken: t.boolean, + }), + json, + ); + + if (error) { + throw new UnexpectedDataError( + `Failed to decode: ${error.errors.join(", ")}`, + { cause: error }, + ); + } + + return value.hasWriteAPIToken; + } + + async updateWriteAPIToken(args: UpdateWriteAPITokenArgs): Promise { + const url = new URL( + "./git/linked-repos/write-api-token", + API_ENDPOINTS.SliceMachineV1, + ); + const res = await this.#fetch(url, { + method: "PUT", + body: { + prismic: { + domain: args.prismic.domain, + }, + git: { + provider: args.git.provider, + owner: args.git.owner, + name: args.git.name, + }, + token: args.token, + }, + }); + + if (!res.ok) { + switch (res.status) { + case 401: + throw new UnauthenticatedError(); + case 403: + throw new UnauthorizedError(); + default: + throw new Error("Failed to update Prismic Write API token."); + } + } + } + + async deleteWriteAPIToken(args: DeleteWriteAPITokenArgs): Promise { + const url = new URL( + "./git/linked-repos/write-api-token", + API_ENDPOINTS.SliceMachineV1, + ); + const res = await this.#fetch(url, { + method: "DELETE", + body: { + prismic: { + domain: args.prismic.domain, + }, + git: { + provider: args.git.provider, + owner: args.git.owner, + name: args.git.name, + }, + }, + }); + + if (!res.ok) { + switch (res.status) { + case 401: + throw new UnauthenticatedError(); + case 403: + throw new UnauthorizedError(); + default: + throw new Error("Failed to delete Prismic Write API token."); + } + } + } + + async #fetch( + url: URL, + config?: { + method?: "POST" | "PUT" | "DELETE"; + body?: unknown; + }, + ) { + const authenticationToken = await this.user.getAuthenticationToken(); + + return await fetch(url, { + method: config?.method, + body: config?.body ? JSON.stringify(config.body) : undefined, + headers: { + Authorization: `Bearer ${authenticationToken}`, + }, + }); + } +} diff --git a/packages/manager/src/managers/git/buildGitRepoSpecifier.ts b/packages/manager/src/managers/git/buildGitRepoSpecifier.ts new file mode 100644 index 0000000000..7a9111f4e5 --- /dev/null +++ b/packages/manager/src/managers/git/buildGitRepoSpecifier.ts @@ -0,0 +1,24 @@ +import { GitRepoSpecifier } from "./types"; + +/** + * Builds a Git repository specifier from its individual parts. + * + * @example + * + * ```typescript + * buildGitRepoSpecifier({ + * provider: "gitHub", + * owner: "foo", + * name: "bar", + * }); + * ``` + * + * @param repoSpecifier - The Git repository specifier. + * + * @returns The specifier in the form of `provider@owner/name`. + */ +export const buildGitRepoSpecifier = ( + repoSpecifier: GitRepoSpecifier, +): string => { + return `${repoSpecifier.provider}@${repoSpecifier.owner}/${repoSpecifier.name}`; +}; diff --git a/packages/manager/src/managers/git/types.ts b/packages/manager/src/managers/git/types.ts new file mode 100644 index 0000000000..f016aba505 --- /dev/null +++ b/packages/manager/src/managers/git/types.ts @@ -0,0 +1,24 @@ +export type Owner = { + provider: "gitHub"; + id: string; + name: string; + // If type is null, the owner's type could not be determined. This can + // happen if a Git provider uses an owner type that we do not support. + // Owners with a null type should still be usable like any other owner. + type: "user" | "team" | null; +}; + +export type GitRepo = { + provider: "gitHub"; + id: string; + owner: string; + name: string; + url: string; + pushedAt: Date; +}; + +export type GitRepoSpecifier = { + provider: "gitHub"; + owner: string; + name: string; +}; diff --git a/packages/manager/test/PrismicAuthManager-login.test.ts b/packages/manager/test/PrismicAuthManager-login.test.ts index a1eace8163..10bf1c4597 100644 --- a/packages/manager/test/PrismicAuthManager-login.test.ts +++ b/packages/manager/test/PrismicAuthManager-login.test.ts @@ -32,6 +32,9 @@ it("retains existing cookies in the auth state file", async (ctx) => { mockPrismicUserAPI(ctx); + // Clear all cookies + await prismicAuthManager.logout(); + await prismicAuthManager.login({ email: "name@example.com", cookies: ["foo=bar"], diff --git a/packages/manager/test/SliceMachineManager-getState.test.ts b/packages/manager/test/SliceMachineManager-getState.test.ts index be9df5d6b8..dc5e700378 100644 --- a/packages/manager/test/SliceMachineManager-getState.test.ts +++ b/packages/manager/test/SliceMachineManager-getState.test.ts @@ -13,6 +13,7 @@ it("returns global Slice Machine state", async () => { }, }); await manager.plugins.initPlugins(); + await manager.user.logout(); const result = await manager.getState(); expect(result.env.endpoints).toStrictEqual({ diff --git a/packages/manager/test/SliceMachineManager-git-checkHasWriteAPIToken.test.ts b/packages/manager/test/SliceMachineManager-git-checkHasWriteAPIToken.test.ts new file mode 100644 index 0000000000..c56fb615e7 --- /dev/null +++ b/packages/manager/test/SliceMachineManager-git-checkHasWriteAPIToken.test.ts @@ -0,0 +1,102 @@ +import { expect, it } from "vitest"; + +import { UnauthenticatedError, UnauthorizedError } from "../src"; + +it("returns true if linked repositories have a Prismic Write API token", async ({ + manager, + api, + login, +}) => { + const prismic = { domain: "domain" }; + const git = { provider: "gitHub", owner: "owner", name: "name" } as const; + + api.mockSliceMachineV1( + "./git/linked-repos/write-api-token", + { hasWriteAPIToken: true }, + { + searchParams: { + repository: prismic.domain, + git: `${git.provider}@${git.owner}/${git.name}`, + }, + }, + ); + + await login(); + const res = await manager.git.checkHasWriteAPIToken({ prismic, git }); + + expect(res).toStrictEqual(true); +}); + +it("returns false if linked repositories do not have a Prismic Write API token", async ({ + manager, + api, + login, +}) => { + const prismic = { domain: "domain" }; + const git = { provider: "gitHub", owner: "owner", name: "name" } as const; + + api.mockSliceMachineV1( + "./git/linked-repos/write-api-token", + { hasWriteAPIToken: false }, + { + searchParams: { + repository: prismic.domain, + git: `${git.provider}@${git.owner}/${git.name}`, + }, + }, + ); + + await login(); + const res = await manager.git.checkHasWriteAPIToken({ prismic, git }); + + expect(res).toStrictEqual(false); +}); + +it("throws UnauthenticatedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos/write-api-token", undefined, { + statusCode: 401, + }); + + await login(); + await expect(() => + manager.git.checkHasWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).rejects.toThrow(UnauthenticatedError); +}); + +it("throws UnauthorizedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos/write-api-token", undefined, { + statusCode: 403, + }); + + await login(); + await expect(() => + manager.git.checkHasWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).rejects.toThrow(UnauthorizedError); +}); + +it("throws UnauthenticatedError if the user is logged out", async ({ + manager, +}) => { + await manager.user.logout(); + + await expect(() => + manager.git.checkHasWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).rejects.toThrow(UnauthenticatedError); +}); diff --git a/packages/manager/test/SliceMachineManager-git-createGitHubAuthState.test.ts b/packages/manager/test/SliceMachineManager-git-createGitHubAuthState.test.ts new file mode 100644 index 0000000000..3cf1fdd7bb --- /dev/null +++ b/packages/manager/test/SliceMachineManager-git-createGitHubAuthState.test.ts @@ -0,0 +1,45 @@ +import { expect, it } from "vitest"; + +import { UnauthenticatedError, UnauthorizedError } from "../src"; + +it("returns a GitHub auth state token", async ({ manager, api, login }) => { + const key = "foo"; + const expiresAt = new Date(); + + api.mockSliceMachineV1( + "./git/github/create-auth-state", + { key, expiresAt: expiresAt.toISOString() }, + { method: "post", checkAuthentication: true }, + ); + + await login(); + const res = await manager.git.createGitHubAuthState(); + + expect(res).toStrictEqual({ key, expiresAt }); +}); + +it("throws UnauthorizedError if the API returns 401", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/github/create-auth-state", undefined, { + method: "post", + statusCode: 401, + }); + + await login(); + await expect(() => manager.git.createGitHubAuthState()).rejects.toThrow( + UnauthorizedError, + ); +}); + +it("throws UnauthenticatedError if the user is logged out", async ({ + manager, +}) => { + await manager.user.logout(); + + await expect(() => manager.git.createGitHubAuthState()).rejects.toThrow( + UnauthenticatedError, + ); +}); diff --git a/packages/manager/test/SliceMachineManager-git-deleteWriteAPIToken.test.ts b/packages/manager/test/SliceMachineManager-git-deleteWriteAPIToken.test.ts new file mode 100644 index 0000000000..120f9b663d --- /dev/null +++ b/packages/manager/test/SliceMachineManager-git-deleteWriteAPIToken.test.ts @@ -0,0 +1,80 @@ +import { expect, it } from "vitest"; + +import { UnauthenticatedError, UnauthorizedError } from "../src"; + +it("deletes a pair of linked repositories' Write API token", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1( + "./git/linked-repos/write-api-token", + { hasWriteAPIToken: true }, + { + method: "delete", + body: { + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }, + }, + ); + + await login(); + await expect( + manager.git.deleteWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).resolves.not.toThrow(); +}); + +it("throws UnauthenticatedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos/write-api-token", undefined, { + method: "delete", + statusCode: 401, + }); + + await login(); + await expect(() => + manager.git.deleteWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).rejects.toThrow(UnauthenticatedError); +}); + +it("throws UnauthorizedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos/write-api-token", undefined, { + method: "delete", + statusCode: 403, + }); + + await login(); + await expect(() => + manager.git.deleteWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).rejects.toThrow(UnauthorizedError); +}); + +it("throws UnauthenticatedError if the user is logged out", async ({ + manager, +}) => { + await manager.user.logout(); + + await expect(() => + manager.git.deleteWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).rejects.toThrow(UnauthenticatedError); +}); diff --git a/packages/manager/test/SliceMachineManager-git-fetchLinkedRepos.test.ts b/packages/manager/test/SliceMachineManager-git-fetchLinkedRepos.test.ts new file mode 100644 index 0000000000..5313b47a67 --- /dev/null +++ b/packages/manager/test/SliceMachineManager-git-fetchLinkedRepos.test.ts @@ -0,0 +1,45 @@ +import { expect, it } from "vitest"; +import { UnauthenticatedError, UnauthorizedError } from "../src"; + +it("returns a list of linked repos for a repo", async ({ + manager, + api, + login, +}) => { + const domain = "domain"; + const repos = [{ provider: "gitHub", owner: "owner", name: "name" }]; + + api.mockSliceMachineV1( + "./git/linked-repos", + { repos }, + { searchParams: { repository: domain } }, + ); + + await login(); + const res = await manager.git.fetchLinkedRepos({ prismic: { domain } }); + + expect(res).toStrictEqual(repos); +}); + +it("throws UnauthorizedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos", undefined, { statusCode: 403 }); + + await login(); + await expect(() => + manager.git.fetchLinkedRepos({ prismic: { domain: "domain" } }), + ).rejects.toThrow(UnauthorizedError); +}); + +it("throws UnauthenticatedError if the user is logged out", async ({ + manager, +}) => { + await manager.user.logout(); + + await expect(() => + manager.git.fetchLinkedRepos({ prismic: { domain: "domain" } }), + ).rejects.toThrow(UnauthenticatedError); +}); diff --git a/packages/manager/test/SliceMachineManager-git-fetchOwners.test.ts b/packages/manager/test/SliceMachineManager-git-fetchOwners.test.ts new file mode 100644 index 0000000000..7ff9bd8dbf --- /dev/null +++ b/packages/manager/test/SliceMachineManager-git-fetchOwners.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "vitest"; + +import { UnauthenticatedError, UnauthorizedError } from "../src"; + +it("returns a list of owners for the user", async ({ manager, api, login }) => { + const owners = [{ provider: "gitHub", id: "id", name: "name", type: "user" }]; + + api.mockSliceMachineV1("./git/owners", { owners }); + + await login(); + const res = await manager.git.fetchOwners(); + + expect(res).toStrictEqual(owners); +}); + +it("throws UnauthorizedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/owners", undefined, { statusCode: 403 }); + + await login(); + await expect(() => manager.git.fetchOwners()).rejects.toThrow( + UnauthorizedError, + ); +}); + +it("throws UnauthenticatedError if the user is logged out", async ({ + manager, +}) => { + await manager.user.logout(); + + await expect(() => manager.git.fetchOwners()).rejects.toThrow( + UnauthenticatedError, + ); +}); diff --git a/packages/manager/test/SliceMachineManager-git-fetchRepos.test.ts b/packages/manager/test/SliceMachineManager-git-fetchRepos.test.ts new file mode 100644 index 0000000000..260c6de6f1 --- /dev/null +++ b/packages/manager/test/SliceMachineManager-git-fetchRepos.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from "vitest"; +import { UnauthenticatedError, UnauthorizedError } from "../src"; + +it("returns a list of repos for an owner", async ({ manager, api, login }) => { + const repos = [ + { + provider: "gitHub", + id: "id", + owner: "owner", + name: "name", + url: "url", + pushedAt: new Date(), + }, + ]; + + api.mockSliceMachineV1( + "./git/repos", + { repos }, + { searchParams: { provider: "gitHub", owner: "owner" } }, + ); + + await login(); + const res = await manager.git.fetchRepos({ + provider: "gitHub", + owner: "owner", + }); + + expect(res).toStrictEqual(repos); +}); + +it("throws UnauthorizedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/repos", undefined, { statusCode: 403 }); + + await login(); + await expect(() => + manager.git.fetchRepos({ + provider: "gitHub", + owner: "owner", + }), + ).rejects.toThrow(UnauthorizedError); +}); + +it("throws UnauthenticatedError if the user is logged out", async ({ + manager, +}) => { + await manager.user.logout(); + + await expect(() => + manager.git.fetchRepos({ + provider: "gitHub", + owner: "owner", + }), + ).rejects.toThrow(UnauthenticatedError); +}); diff --git a/packages/manager/test/SliceMachineManager-git-linkRepo.test.ts b/packages/manager/test/SliceMachineManager-git-linkRepo.test.ts new file mode 100644 index 0000000000..0f57b57d38 --- /dev/null +++ b/packages/manager/test/SliceMachineManager-git-linkRepo.test.ts @@ -0,0 +1,56 @@ +import { expect, it } from "vitest"; +import { UnauthenticatedError, UnauthorizedError } from "../src"; + +it("links a Git repository to a Prismic repository", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos", undefined, { + method: "put", + body: { + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }, + }); + + await login(); + await expect( + manager.git.linkRepo({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).resolves.not.toThrow(); +}); + +it("throws UnauthorizedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos", undefined, { + method: "put", + statusCode: 403, + }); + + await login(); + await expect(() => + manager.git.linkRepo({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).rejects.toThrow(UnauthorizedError); +}); + +it("throws UnauthenticatedError if the user is logged out", async ({ + manager, +}) => { + await manager.user.logout(); + + await expect(() => + manager.git.linkRepo({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).rejects.toThrow(UnauthenticatedError); +}); diff --git a/packages/manager/test/SliceMachineManager-git-unlinkRepo.test.ts b/packages/manager/test/SliceMachineManager-git-unlinkRepo.test.ts new file mode 100644 index 0000000000..7394f011db --- /dev/null +++ b/packages/manager/test/SliceMachineManager-git-unlinkRepo.test.ts @@ -0,0 +1,56 @@ +import { expect, it } from "vitest"; +import { UnauthenticatedError, UnauthorizedError } from "../src"; + +it("unlinks a Git repository from a Prismic repository", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos", undefined, { + method: "delete", + body: { + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }, + }); + + await login(); + await expect( + manager.git.unlinkRepo({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).resolves.not.toThrow(); +}); + +it("throws UnauthorizedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos", undefined, { + method: "delete", + statusCode: 403, + }); + + await login(); + await expect(() => + manager.git.unlinkRepo({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).rejects.toThrow(UnauthorizedError); +}); + +it("throws UnauthenticatedError if the user is logged out", async ({ + manager, +}) => { + await manager.user.logout(); + + await expect(() => + manager.git.unlinkRepo({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + }), + ).rejects.toThrow(UnauthenticatedError); +}); diff --git a/packages/manager/test/SliceMachineManager-git-updateWriteAPIToken.test.ts b/packages/manager/test/SliceMachineManager-git-updateWriteAPIToken.test.ts new file mode 100644 index 0000000000..4f900c30d0 --- /dev/null +++ b/packages/manager/test/SliceMachineManager-git-updateWriteAPIToken.test.ts @@ -0,0 +1,85 @@ +import { expect, it } from "vitest"; + +import { UnauthenticatedError, UnauthorizedError } from "../src"; + +it("updates a pair of linked repositories' Write API token", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1( + "./git/linked-repos/write-api-token", + { hasWriteAPIToken: true }, + { + method: "put", + body: { + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + token: "token", + }, + }, + ); + + await login(); + await expect( + manager.git.updateWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + token: "token", + }), + ).resolves.not.toThrow(); +}); + +it("throws UnauthenticatedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos/write-api-token", undefined, { + method: "put", + statusCode: 401, + }); + + await login(); + await expect(() => + manager.git.updateWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + token: "token", + }), + ).rejects.toThrow(UnauthenticatedError); +}); + +it("throws UnauthorizedError if the API returns 403", async ({ + manager, + api, + login, +}) => { + api.mockSliceMachineV1("./git/linked-repos/write-api-token", undefined, { + method: "put", + statusCode: 403, + }); + + await login(); + await expect(() => + manager.git.updateWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + token: "token", + }), + ).rejects.toThrow(UnauthorizedError); +}); + +it("throws UnauthenticatedError if the user is logged out", async ({ + manager, +}) => { + await manager.user.logout(); + + await expect(() => + manager.git.updateWriteAPIToken({ + prismic: { domain: "domain" }, + git: { provider: "gitHub", owner: "owner", name: "name" }, + token: "token", + }), + ).rejects.toThrow(UnauthenticatedError); +}); diff --git a/packages/manager/test/SliceMachineManager-project-installDependencies.test.ts b/packages/manager/test/SliceMachineManager-project-installDependencies.test.ts index 23dc83db5e..e05011081d 100644 --- a/packages/manager/test/SliceMachineManager-project-installDependencies.test.ts +++ b/packages/manager/test/SliceMachineManager-project-installDependencies.test.ts @@ -6,18 +6,6 @@ import { watchStd } from "./__testutils__/watchStd"; import { createSliceMachineManager } from "../src"; -vi.mock("execa", async () => { - const execa: typeof import("execa") = await vi.importActual("execa"); - - return { - ...execa, - execaCommand: ((command: string, options: Record) => { - // Replace command with simple `echo` - return execa.execaCommand(`echo 'mock command ran: ${command}'`, options); - }) as typeof execa.execaCommand, - }; -}); - it("installs dependencies", async () => { const adapter = createTestPlugin(); const cwd = await createTestProject({ adapter }); diff --git a/packages/manager/test/SliceMachineManager-telemetry-getExperimentVariant.test.ts b/packages/manager/test/SliceMachineManager-telemetry-getExperimentVariant.test.ts index 6b8e5af07f..3c8b3cc5ea 100644 --- a/packages/manager/test/SliceMachineManager-telemetry-getExperimentVariant.test.ts +++ b/packages/manager/test/SliceMachineManager-telemetry-getExperimentVariant.test.ts @@ -1,34 +1,10 @@ -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import { createTestPlugin } from "./__testutils__/createTestPlugin"; import { createTestProject } from "./__testutils__/createTestProject"; import { createSliceMachineManager } from "../src"; -vi.mock("@amplitude/experiment-node-server", () => { - const MockAmplitudeClient = { - fetchV2: vi.fn(() => { - return { - "test-variant-on": { - value: "on", - }, - "test-variant-off": { - value: "off", - }, - }; - }), - }; - - const MockExperiment = { - initializeRemote: vi.fn(() => MockAmplitudeClient), - }; - - return { - Experiment: MockExperiment, - RemoteEvaluationClient: MockAmplitudeClient, - }; -}); - it("get the experiment 'on' value for a specific variant", async () => { const adapter = createTestPlugin(); const cwd = await createTestProject({ adapter }); diff --git a/packages/manager/test/SliceMachineManager-telemetry-group.test.ts b/packages/manager/test/SliceMachineManager-telemetry-group.test.ts index 074176aa94..95310e3f1c 100644 --- a/packages/manager/test/SliceMachineManager-telemetry-group.test.ts +++ b/packages/manager/test/SliceMachineManager-telemetry-group.test.ts @@ -6,24 +6,6 @@ import { createTestProject } from "./__testutils__/createTestProject"; import { createSliceMachineManager } from "../src"; -vi.mock("@segment/analytics-node", () => { - const MockSegmentClient = vi.fn(); - - MockSegmentClient.prototype.group = vi.fn( - (_message: unknown, callback?: (error?: unknown) => void) => { - if (callback) { - callback(); - } - }, - ); - - MockSegmentClient.prototype.on = vi.fn(); - - return { - Analytics: MockSegmentClient, - }; -}); - it("sends a group payload to Segment", async () => { const adapter = createTestPlugin(); const cwd = await createTestProject({ adapter }); diff --git a/packages/manager/test/SliceMachineManager-telemetry-identify.test.ts b/packages/manager/test/SliceMachineManager-telemetry-identify.test.ts index e15879a2fc..c12be807ed 100644 --- a/packages/manager/test/SliceMachineManager-telemetry-identify.test.ts +++ b/packages/manager/test/SliceMachineManager-telemetry-identify.test.ts @@ -6,24 +6,6 @@ import { createTestProject } from "./__testutils__/createTestProject"; import { createSliceMachineManager } from "../src"; -vi.mock("@segment/analytics-node", () => { - const MockSegmentClient = vi.fn(); - - MockSegmentClient.prototype.identify = vi.fn( - (_message: unknown, callback?: (error?: unknown) => void) => { - if (callback) { - callback(); - } - }, - ); - - MockSegmentClient.prototype.on = vi.fn(); - - return { - Analytics: MockSegmentClient, - }; -}); - it("sends an identification payload to Segment", async () => { const adapter = createTestPlugin(); const cwd = await createTestProject({ adapter }); diff --git a/packages/manager/test/SliceMachineManager-telemetry-track.test.ts b/packages/manager/test/SliceMachineManager-telemetry-track.test.ts index 5cd9c41b26..19ffba365f 100644 --- a/packages/manager/test/SliceMachineManager-telemetry-track.test.ts +++ b/packages/manager/test/SliceMachineManager-telemetry-track.test.ts @@ -10,24 +10,6 @@ import { mockSliceMachineAPI } from "./__testutils__/mockSliceMachineAPI"; import { createSliceMachineManager, Environment } from "../src"; -vi.mock("@segment/analytics-node", () => { - const MockSegmentClient = vi.fn(); - - MockSegmentClient.prototype.track = vi.fn( - (_message: unknown, callback?: (error?: unknown) => void) => { - if (callback) { - callback(); - } - }, - ); - - MockSegmentClient.prototype.on = vi.fn(); - - return { - Analytics: MockSegmentClient, - }; -}); - it("sends a given event to Segment", async () => { const adapter = createTestPlugin(); const cwd = await createTestProject({ adapter }); diff --git a/packages/manager/test/__setup__.ts b/packages/manager/test/__setup__.ts index 75040b5b5c..cd76ffbc28 100644 --- a/packages/manager/test/__setup__.ts +++ b/packages/manager/test/__setup__.ts @@ -4,12 +4,19 @@ import { createMockFactory, MockFactory } from "@prismicio/mock"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as os from "node:os"; +import { createSliceMachineManager, SliceMachineManager } from "../src"; +import { createTestPlugin } from "./__testutils__/createTestPlugin"; +import { createTestProject } from "./__testutils__/createTestProject"; +import { APIFixture, createAPIFixture } from "./__testutils__/createAPIFixture"; declare module "vitest" { export interface TestContext { mockPrismic: MockFactory; msw: SetupServer; sliceMachineUIDirectory: string; + manager: SliceMachineManager; + api: APIFixture; + login: () => Promise; } } @@ -130,6 +137,71 @@ vi.mock("module", async () => { } as typeof actual; }); +vi.mock("@segment/analytics-node", async () => { + const actual: typeof import("@segment/analytics-node") = + await vi.importActual("@segment/analytics-node"); + + vi.spyOn(actual.Analytics.prototype, "track").mockImplementation( + (_params, callback) => { + callback?.(); + }, + ); + + vi.spyOn(actual.Analytics.prototype, "identify").mockImplementation( + (_params, callback) => { + callback?.(); + }, + ); + + vi.spyOn(actual.Analytics.prototype, "group").mockImplementation( + (_params, callback) => { + callback?.(); + }, + ); + + vi.spyOn(actual.Analytics.prototype, "on").mockImplementation( + () => actual.Analytics.prototype, + ); + + return actual; +}); + +vi.mock("@amplitude/experiment-node-server", () => { + class RemoteEvaluationClient { + fetchV2() { + return { + "test-variant-on": { + value: "on", + }, + "test-variant-off": { + value: "off", + }, + }; + } + } + + const Experiment = { + initializeRemote: vi.fn(() => new RemoteEvaluationClient()), + }; + + return { + Experiment, + RemoteEvaluationClient, + }; +}); + +vi.mock("execa", async () => { + const execa: typeof import("execa") = await vi.importActual("execa"); + + return { + ...execa, + execaCommand: ((command: string, options: Record) => { + // Replace command with simple `echo` + return execa.execaCommand(`echo 'mock command ran: ${command}'`, options); + }) as typeof execa.execaCommand, + }; +}); + const mswServer = setupServer(); beforeAll(() => { @@ -137,7 +209,7 @@ beforeAll(() => { }); beforeEach(async (ctx) => { - ctx.mockPrismic = createMockFactory({ seed: ctx.meta.name }); + ctx.mockPrismic = createMockFactory({ seed: ctx.task.name }); ctx.msw = mswServer; ctx.msw.resetHandlers(); @@ -151,6 +223,44 @@ beforeEach(async (ctx) => { ctx.sliceMachineUIDirectory = path.dirname( MOCK_SLICE_MACHINE_PACKAGE_JSON_PATH, ); + + const adapter = createTestPlugin(); + const cwd = await createTestProject({ adapter }); + const manager = createSliceMachineManager({ + nativePlugins: { [adapter.meta.name]: adapter }, + cwd, + }); + await manager.plugins.initPlugins(); + + ctx.manager = manager; + ctx.login = async () => { + await manager.user.login({ + email: `name@example.com`, + cookies: ["prismic-auth=token", "SESSION=session"], + }); + }; + + const api = createAPIFixture({ manager, mswServer }); + api.mockPrismicUser( + "./profile", + { + userId: "userId", + shortId: "shortId", + intercomHash: "intercomHash", + email: "email", + firstName: "firstName", + lastName: "lastName", + }, + { checkAuthentication: false }, + ); + api.mockPrismicAuthentication("./validate", undefined, { + checkAuthentication: false, + }); + api.mockPrismicAuthentication("./refreshtoken", undefined, { + checkAuthentication: false, + }); + + ctx.api = api; }); afterAll(() => { diff --git a/packages/manager/test/__testutils__/createAPIFixture.ts b/packages/manager/test/__testutils__/createAPIFixture.ts new file mode 100644 index 0000000000..8f60d3f020 --- /dev/null +++ b/packages/manager/test/__testutils__/createAPIFixture.ts @@ -0,0 +1,96 @@ +import { SetupServer } from "msw/lib/node"; +import { rest } from "msw"; + +import { SliceMachineManager } from "../../src"; + +type MockOptions = { + method?: keyof typeof rest; + statusCode?: number; + checkAuthentication?: boolean; + searchParams?: Record; + body?: SerializableValue; +}; + +type SerializableValueObject = { [Key in string]: SerializableValue } & { + [Key in string]?: SerializableValue | undefined; +}; +type SerializableValueArray = + | SerializableValue[] + | readonly SerializableValue[]; +type SerializableValuePrimitive = string | number | boolean | Date | null; +export type SerializableValue = + | SerializableValuePrimitive + | SerializableValueObject + | SerializableValueArray; + +export type APIFixture = { + [P in keyof ReturnType< + SliceMachineManager["getAPIEndpoints"] + > as `mock${P}`]: ( + path: string, + response?: SerializableValue, + options?: MockOptions, + ) => void; +}; + +export const createAPIFixture = (args: { + manager: SliceMachineManager; + mswServer: SetupServer; +}): APIFixture => { + const apiEndpoints = args.manager.getAPIEndpoints(); + + const api = {} as APIFixture; + + for (const key in apiEndpoints) { + api[`mock${key}` as keyof typeof api] = (path, response, options) => { + const apiEndpoint = apiEndpoints[key as keyof typeof apiEndpoints]; + const method = options?.method ?? "get"; + + const handler = rest[method]( + new URL(path, apiEndpoint).toString(), + async (req, res, ctx) => { + // TODO(DT-1919): Enable by default after fixing "Error [ERR_STREAM_PREMATURE_CLOSE]: Premature close" error. + if (options?.checkAuthentication) { + const authenticationToken = + await args.manager.user.getAuthenticationToken(); + if ( + req.headers.get("Authorization") !== + `Bearer ${authenticationToken}` + ) { + return; + } + } + + if (options?.searchParams) { + for (const name in options.searchParams) { + if ( + req.url.searchParams.get(name) !== options.searchParams[name] + ) { + return; + } + } + } + + if (options?.body) { + if ( + JSON.stringify(await req.json()) !== JSON.stringify(options.body) + ) { + return; + } + } + + return res( + typeof response === "object" + ? ctx.json(response) + : ctx.body(response), + ctx.status(options?.statusCode ?? 200), + ); + }, + ); + + args.mswServer.use(handler); + }; + } + + return api; +};