diff --git a/packages/next-drupal/src/client.ts b/packages/next-drupal/src/client.ts index e8645702..0310ff69 100644 --- a/packages/next-drupal/src/client.ts +++ b/packages/next-drupal/src/client.ts @@ -246,79 +246,10 @@ export class DrupalClient { }, } - // Using the auth set on the client. - // TODO: Abstract this to a re-usable. if (withAuth) { - this.debug(`Using authenticated request.`) - - if (withAuth === true) { - if (typeof this._auth === "undefined") { - throw new Error( - "auth is not configured. See https://next-drupal.org/docs/client/auth" - ) - } - - // By default, if withAuth is set to true, we use the auth configured - // in the client constructor. - if (typeof this._auth === "function") { - this.debug(`Using custom auth callback.`) - - init["headers"]["Authorization"] = this._auth() - } else if (typeof this._auth === "string") { - this.debug(`Using custom authorization header.`) - - init["headers"]["Authorization"] = this._auth - } else if (typeof this._auth === "object") { - this.debug(`Using custom auth credentials.`) - - if (isBasicAuth(this._auth)) { - const basic = Buffer.from( - `${this._auth.username}:${this._auth.password}` - ).toString("base64") - - init["headers"]["Authorization"] = `Basic ${basic}` - } else if (isClientIdSecretAuth(this._auth)) { - // Use the built-in client_credentials grant. - this.debug(`Using default auth (client_credentials).`) - - // Fetch an access token and add it to the request. - // Access token can be fetched from cache or using a custom auth method. - const token = await this.getAccessToken(this._auth) - if (token) { - init["headers"]["Authorization"] = `Bearer ${token.access_token}` - } - } /* c8 ignore next 4 */ else if (isAccessTokenAuth(this._auth)) { - init["headers"]["Authorization"] = - `${this._auth.token_type} ${this._auth.access_token}` - } - } - } else if (typeof withAuth === "string") { - this.debug(`Using custom authorization header.`) - - init["headers"]["Authorization"] = withAuth - } /* c8 ignore next 4 */ else if (typeof withAuth === "function") { - this.debug(`Using custom authorization callback.`) - - init["headers"]["Authorization"] = withAuth() - } else if (isBasicAuth(withAuth)) { - this.debug(`Using basic authorization header.`) - - const basic = Buffer.from( - `${withAuth.username}:${withAuth.password}` - ).toString("base64") - - init["headers"]["Authorization"] = `Basic ${basic}` - } else if (isClientIdSecretAuth(withAuth)) { - // Fetch an access token and add it to the request. - // Access token can be fetched from cache or using a custom auth method. - const token = await this.getAccessToken(withAuth) - if (token) { - init["headers"]["Authorization"] = `Bearer ${token.access_token}` - } - } /* c8 ignore next 4 */ else if (isAccessTokenAuth(withAuth)) { - init["headers"]["Authorization"] = - `${withAuth.token_type} ${withAuth.access_token}` - } + init.headers["Authorization"] = await this.getAuthorizationHeader( + withAuth === true ? this._auth : withAuth + ) } if (this.fetcher) { @@ -332,6 +263,41 @@ export class DrupalClient { return await fetch(input, init) } + async getAuthorizationHeader(auth: DrupalClientAuth) { + let header: string + + if (isBasicAuth(auth)) { + const basic = Buffer.from(`${auth.username}:${auth.password}`).toString( + "base64" + ) + header = `Basic ${basic}` + this.debug("Using basic authorization header.") + } else if (isClientIdSecretAuth(auth)) { + // Fetch an access token and add it to the request. getAccessToken() + // throws an error if it fails to get an access token. + const token = await this.getAccessToken(auth) + header = `Bearer ${token.access_token}` + this.debug( + "Using access token authorization header retrieved from Client Id/Secret." + ) + } else if (isAccessTokenAuth(auth)) { + header = `${auth.token_type} ${auth.access_token}` + this.debug("Using access token authorization header.") + } else if (typeof auth === "string") { + header = auth + this.debug("Using custom authorization header.") + } else if (typeof auth === "function") { + header = auth() + this.debug("Using custom authorization callback.") + } else { + throw new Error( + "auth is not configured. See https://next-drupal.org/docs/client/auth" + ) + } + + return header + } + async createResource( type: string, body: JsonApiCreateResourceBody, @@ -1410,10 +1376,11 @@ export class DrupalClient { this.debug(`Fetching new access token.`) - const basic = Buffer.from(`${auth.clientId}:${auth.clientSecret}`).toString( - "base64" - ) - + // Use BasicAuth to retrieve the access token. + const credentials: DrupalClientAuthUsernamePassword = { + username: auth.clientId, + password: auth.clientSecret, + } let body = `grant_type=client_credentials` if (opts?.scope) { @@ -1425,7 +1392,7 @@ export class DrupalClient { const response = await this.fetch(url.toString(), { method: "POST", headers: { - Authorization: `Basic ${basic}`, + Authorization: await this.getAuthorizationHeader(credentials), Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", }, diff --git a/packages/next-drupal/tests/DrupalClient/__snapshots__/fetch-related-methods.test.ts.snap b/packages/next-drupal/tests/DrupalClient/__snapshots__/resource-methods.test.ts.snap similarity index 95% rename from packages/next-drupal/tests/DrupalClient/__snapshots__/fetch-related-methods.test.ts.snap rename to packages/next-drupal/tests/DrupalClient/__snapshots__/resource-methods.test.ts.snap index 120346a7..f75713ac 100644 --- a/packages/next-drupal/tests/DrupalClient/__snapshots__/fetch-related-methods.test.ts.snap +++ b/packages/next-drupal/tests/DrupalClient/__snapshots__/resource-methods.test.ts.snap @@ -1,196 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`fetch() allows fetching custom url 1`] = ` -{ - "data": { - "attributes": { - "body": { - "format": "basic_html", - "processed": "

There's nothing like having your own supply of fresh herbs, readily available and close at hand to use while cooking. Whether you have a large garden or a small kitchen window sill, there's always enough room for something home grown.

-

Outdoors

-

Mint

-

Mint is a great plant to grow as it's hardy and can grow in almost any soil. Mint can grow wild, so keep it contained in a pot or it might spread and take over your whole garden.

-

Sage

-

Like mint, sage is another prolific growing plant and will take over your garden if you let it. Highly aromatic, the sage plant can be planted in a pot or flower bed in well drained soil. The best way to store the herb is to sun dry the leaves and store in a cool, dark cupboard in a sealed container.

-

Rosemary

-

Rosemary plants grow into lovely shrubs. Easily grown from cuttings, rosemary plants do not like freezing temperatures so keep pots or planted bushes near the home to shelter them from the cold. It grows well in pots as it likes dry soil, but can survive well in the ground too. If pruning rosemary to encourage it into a better shape, save the branches and hang them upside down to preserve the flavor and use in food.

-

Indoors

-

Basil

-

Perfect in sunny spot on a kitchen window sill. Basil is an annual plant, so will die off in the autumn, so it's a good idea to harvest it in the summer if you have an abundance and dry it. Picked basil stays fresh longer if it is placed in water (like fresh flowers). A great way to store basil is to make it into pesto!

-

Chives

-

A versatile herb, chives can grow well indoors. Ensure the plant is watered well, and gets plenty of light. Remember to regularly trim the chives. This prevents the flowers from developing and encourages new growth.

-

Coriander (Cilantro)

-

Coriander can grow indoors, but unlike the other herbs, it doesn't like full sun. If you have a south facing kitchen window, this isn't the place for it. Although not as thirsty as basil, coriander doesn't like dry soil so don't forget to water it! Cut coriander is best stored in the fridge.

-", - "summary": null, - "value": "

There's nothing like having your own supply of fresh herbs, readily available and close at hand to use while cooking. Whether you have a large garden or a small kitchen window sill, there's always enough room for something home grown.

-

Outdoors

-

Mint

-

Mint is a great plant to grow as it's hardy and can grow in almost any soil. Mint can grow wild, so keep it contained in a pot or it might spread and take over your whole garden.

-

Sage

-

Like mint, sage is another prolific growing plant and will take over your garden if you let it. Highly aromatic, the sage plant can be planted in a pot or flower bed in well drained soil. The best way to store the herb is to sun dry the leaves and store in a cool, dark cupboard in a sealed container.

-

Rosemary

-

Rosemary plants grow into lovely shrubs. Easily grown from cuttings, rosemary plants do not like freezing temperatures so keep pots or planted bushes near the home to shelter them from the cold. It grows well in pots as it likes dry soil, but can survive well in the ground too. If pruning rosemary to encourage it into a better shape, save the branches and hang them upside down to preserve the flavor and use in food.

-

Indoors

-

Basil

-

Perfect in sunny spot on a kitchen window sill. Basil is an annual plant, so will die off in the autumn, so it's a good idea to harvest it in the summer if you have an abundance and dry it. Picked basil stays fresh longer if it is placed in water (like fresh flowers). A great way to store basil is to make it into pesto!

-

Chives

-

A versatile herb, chives can grow well indoors. Ensure the plant is watered well, and gets plenty of light. Remember to regularly trim the chives. This prevents the flowers from developing and encourages new growth.

-

Coriander (Cilantro)

-

Coriander can grow indoors, but unlike the other herbs, it doesn't like full sun. If you have a south facing kitchen window, this isn't the place for it. Although not as thirsty as basil, coriander doesn't like dry soil so don't forget to water it! Cut coriander is best stored in the fridge.

-", - }, - "changed": "2022-03-21T10:52:42+00:00", - "content_translation_outdated": false, - "content_translation_source": "und", - "created": "2022-03-21T10:52:42+00:00", - "default_langcode": true, - "drupal_internal__nid": 10, - "drupal_internal__vid": 20, - "langcode": "en", - "moderation_state": "published", - "path": { - "alias": "/articles/give-it-a-go-and-grow-your-own-herbs", - "langcode": "en", - "pid": 85, - }, - "promote": true, - "revision_log": null, - "revision_timestamp": "2022-03-21T10:52:42+00:00", - "revision_translation_affected": null, - "status": true, - "sticky": false, - "title": "Give it a go and grow your own herbs", - }, - "id": "52837ad0-f218-46bd-a106-5710336b7053", - "links": { - "self": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053?resourceVersion=id%3A20", - }, - }, - "relationships": { - "field_media_image": { - "data": { - "id": "e5091a16-134e-400d-8393-cfe4eccbcaa2", - "meta": { - "drupal_internal__target_id": 10, - }, - "type": "media--image", - }, - "links": { - "related": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053/field_media_image?resourceVersion=id%3A20", - }, - "self": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053/relationships/field_media_image?resourceVersion=id%3A20", - }, - }, - }, - "field_tags": { - "data": [ - { - "id": "dcd81647-71b7-48cb-b555-e20322bcb7a7", - "meta": { - "drupal_internal__target_id": 14, - }, - "type": "taxonomy_term--tags", - }, - { - "id": "60d20a4c-9d42-4b25-b717-3af3cba6abe8", - "meta": { - "drupal_internal__target_id": 23, - }, - "type": "taxonomy_term--tags", - }, - { - "id": "57a1d9f6-23a6-4215-a8a9-582202cd938d", - "meta": { - "drupal_internal__target_id": 16, - }, - "type": "taxonomy_term--tags", - }, - ], - "links": { - "related": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053/field_tags?resourceVersion=id%3A20", - }, - "self": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053/relationships/field_tags?resourceVersion=id%3A20", - }, - }, - }, - "node_type": { - "data": { - "id": "a145b65a-e660-4f5d-ac0d-bd2ff9e3f0b0", - "meta": { - "drupal_internal__target_id": "article", - }, - "type": "node_type--node_type", - }, - "links": { - "related": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053/node_type?resourceVersion=id%3A20", - }, - "self": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053/relationships/node_type?resourceVersion=id%3A20", - }, - }, - }, - "revision_uid": { - "data": { - "id": "dd9c916d-4d66-4bff-a851-eeba0cf7673a", - "meta": { - "drupal_internal__target_id": 5, - }, - "type": "user--user", - }, - "links": { - "related": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053/revision_uid?resourceVersion=id%3A20", - }, - "self": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053/relationships/revision_uid?resourceVersion=id%3A20", - }, - }, - }, - "uid": { - "data": { - "id": "dd9c916d-4d66-4bff-a851-eeba0cf7673a", - "meta": { - "drupal_internal__target_id": 5, - }, - "type": "user--user", - }, - "links": { - "related": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053/uid?resourceVersion=id%3A20", - }, - "self": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053/relationships/uid?resourceVersion=id%3A20", - }, - }, - }, - }, - "type": "node--article", - }, - "jsonapi": { - "meta": { - "links": { - "self": { - "href": "http://jsonapi.org/format/1.0/", - }, - }, - }, - "version": "1.0", - }, - "links": { - "self": { - "href": "https://tests.next-drupal.org/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053", - }, - }, -} -`; - exports[`getIndex() fetches the JSON:API index 1`] = ` { "data": [], diff --git a/packages/next-drupal/tests/DrupalClient/basic-methods.test.ts b/packages/next-drupal/tests/DrupalClient/basic-methods.test.ts index c8684a47..ff83e0f0 100644 --- a/packages/next-drupal/tests/DrupalClient/basic-methods.test.ts +++ b/packages/next-drupal/tests/DrupalClient/basic-methods.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, jest, test } from "@jest/globals" -import { NextApiRequest, NextApiResponse } from "next" -import { DRAFT_DATA_COOKIE_NAME, DrupalClient, JsonApiErrors } from "../../src" +import { DrupalClient, JsonApiErrors } from "../../src" import { BASE_URL, mockLogger, spyOnFetch, spyOnFetchOnce } from "../utils" import type { DrupalNode, JsonApiError, Serializer } from "../../src" @@ -8,10 +7,6 @@ afterEach(() => { jest.restoreAllMocks() }) -describe("buildMenuTree()", () => { - test.todo("add tests") -}) - describe("buildUrl()", () => { const client = new DrupalClient(BASE_URL) @@ -243,199 +238,6 @@ describe("getErrorsFromResponse()", () => { }) }) -describe("preview()", () => { - // Get values from our mocked request. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { slug, resourceVersion, plugin, secret, ...draftData } = - new NextApiRequest().query - const dataCookie = `${DRAFT_DATA_COOKIE_NAME}=${encodeURIComponent( - JSON.stringify({ slug, resourceVersion, ...draftData }) - )}; Path=/; HttpOnly; SameSite=None; Secure` - const validationPayload = { - slug, - maxAge: 30, - } - - test("turns on preview mode and clears preview data", async () => { - const request = new NextApiRequest() - const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) - spyOnFetch({ responseBody: validationPayload }) - - await client.preview(request, response) - - expect(response.clearPreviewData).toBeCalledTimes(1) - expect(response.setPreviewData).toBeCalledWith({ - resourceVersion, - plugin, - ...validationPayload, - }) - }) - - test("does not enable preview mode if validation fails", async () => { - const logger = mockLogger() - const request = new NextApiRequest() - const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL, { debug: true, logger }) - const status = 403 - const message = "mock fail" - spyOnFetch({ - responseBody: { message }, - status, - headers: { - "Content-Type": "application/json", - }, - }) - - await client.preview(request, response) - - expect(logger.debug).toBeCalledWith( - `Draft url validation error: ${message}` - ) - expect(response.setPreviewData).toBeCalledTimes(0) - expect(response.statusCode).toBe(status) - expect(response.json).toBeCalledWith({ message }) - }) - - test("does not turn on draft mode by default", async () => { - const request = new NextApiRequest() - const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) - spyOnFetch({ responseBody: validationPayload }) - - await client.preview(request, response) - - expect(response.setDraftMode).toBeCalledTimes(0) - - // Also check for no draft data cookie. - const cookies = response.getHeader("Set-Cookie") - expect(cookies[cookies.length - 1]).not.toBe(dataCookie) - }) - - test("optionally turns on draft mode", async () => { - const request = new NextApiRequest() - const response = new NextApiResponse() - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - debug: true, - logger, - }) - spyOnFetch({ responseBody: validationPayload }) - - const options = { enable: true } - await client.preview(request, response, options) - - expect(response.setDraftMode).toBeCalledWith(options) - - // Also check for draft data cookie. - const cookies = response.getHeader("Set-Cookie") - expect(cookies[cookies.length - 1]).toBe(dataCookie) - - expect(logger.debug).toHaveBeenLastCalledWith("Draft mode enabled.") - }) - - test("updates preview mode cookie’s sameSite flag", async () => { - const request = new NextApiRequest() - const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) - spyOnFetch({ responseBody: validationPayload }) - - // Our mock response.setPreviewData() does not set a cookie, so we set one. - const previewCookie = - "__next_preview_data=secret-data; Path=/; HttpOnly; SameSite=Lax" - response.setHeader("Set-Cookie", [ - previewCookie, - ...response.getHeader("Set-Cookie"), - ]) - - const cookies = response.getHeader("Set-Cookie") - cookies[0] = cookies[0].replace("SameSite=Lax", "SameSite=None; Secure") - - await client.preview(request, response) - - expect(response.getHeader).toHaveBeenLastCalledWith("Set-Cookie") - expect(response.setHeader).toHaveBeenLastCalledWith("Set-Cookie", cookies) - expect(response.getHeader("Set-Cookie")).toStrictEqual(cookies) - }) - - test("redirects to the slug path", async () => { - const request = new NextApiRequest() - const response = new NextApiResponse() - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { debug: true, logger }) - spyOnFetch({ responseBody: validationPayload }) - - await client.preview(request, response) - - expect(response.setPreviewData).toBeCalledWith({ - resourceVersion, - plugin, - ...validationPayload, - }) - expect(response.writeHead).toBeCalledWith(307, { Location: slug }) - expect(logger.debug).toHaveBeenLastCalledWith("Preview mode enabled.") - }) - - test("returns a 422 response on error", async () => { - const request = new NextApiRequest() - const response = new NextApiResponse() - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { debug: true, logger }) - const message = "mock internal error" - response.clearPreviewData = jest.fn(() => { - throw new Error(message) - }) - - await client.preview(request, response) - - expect(logger.debug).toHaveBeenLastCalledWith(`Preview failed: ${message}`) - expect(response.status).toBeCalledWith(422) - expect(response.end).toHaveBeenCalled() - }) -}) - -describe("previewDisable()", () => { - test("clears preview data", async () => { - const request = new NextApiRequest() - const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) - - await client.previewDisable(request, response) - expect(response.clearPreviewData).toBeCalledTimes(1) - }) - - test("disables draft mode", async () => { - const request = new NextApiRequest() - const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) - - await client.previewDisable(request, response) - expect(response.setDraftMode).toBeCalledWith({ enable: false }) - }) - - test("deletes the draft cookie", async () => { - const request = new NextApiRequest() - const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) - - await client.previewDisable(request, response) - const cookies = response.getHeader("Set-Cookie") - expect(cookies[cookies.length - 1]).toBe( - `${DRAFT_DATA_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=None; Secure` - ) - }) - - test('redirects to "/"', async () => { - const request = new NextApiRequest() - const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) - - await client.previewDisable(request, response) - expect(response.writeHead).toBeCalledWith(307, { Location: "/" }) - expect(response.end).toBeCalled() - }) -}) - describe("throwError()", () => { test("throws the error", () => { const client = new DrupalClient(BASE_URL) diff --git a/packages/next-drupal/tests/DrupalClient/fetch-methods.test.ts b/packages/next-drupal/tests/DrupalClient/fetch-methods.test.ts new file mode 100644 index 00000000..6f97b044 --- /dev/null +++ b/packages/next-drupal/tests/DrupalClient/fetch-methods.test.ts @@ -0,0 +1,411 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { DrupalClient } from "../../src" +import { + BASE_URL, + mockLogger, + mocks, + spyOnFetch, + spyOnFetchOnce, +} from "../utils" +import type { AccessToken, DrupalClientAuth } from "../../src" + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe("fetch()", () => { + const defaultInit = { + credentials: "include", + headers: { + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + } + const mockUrl = "https://example.com/mock-url" + const authHeader = mocks.auth.customAuthenticationHeader + + test("uses global fetch by default", async () => { + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { + debug: true, + logger, + }) + const mockResponseBody = { success: true } + const mockUrl = "https://example.com/mock-url" + const mockInit = { + priority: "high", + } + const fetchSpy = spyOnFetch({ responseBody: mockResponseBody }) + + const response = await client.fetch(mockUrl, mockInit) + + expect(fetchSpy).toBeCalledTimes(1) + expect(fetchSpy).toBeCalledWith( + mockUrl, + expect.objectContaining({ + ...defaultInit, + ...mockInit, + }) + ) + expect(response.headers.get("content-type")).toEqual( + "application/vnd.api+json" + ) + expect(await response.json()).toMatchObject(mockResponseBody) + expect(logger.debug).toHaveBeenLastCalledWith( + `Using default fetch, fetching: ${mockUrl}` + ) + }) + + test("allows for custom fetcher", async () => { + const logger = mockLogger() + const customFetch = jest.fn() + + const client = new DrupalClient(BASE_URL, { + fetcher: customFetch, + debug: true, + logger, + }) + const mockUrl = "https://example.com/mock-url" + const mockInit = { + priority: "high", + } + + await client.fetch(mockUrl, mockInit) + + expect(customFetch).toBeCalledTimes(1) + expect(customFetch).toHaveBeenCalledWith( + mockUrl, + expect.objectContaining({ + ...mockInit, + ...defaultInit, + }) + ) + expect(logger.debug).toHaveBeenLastCalledWith( + `Using custom fetcher, fetching: ${mockUrl}` + ) + }) + + test("allows setting custom headers", async () => { + const customFetch = jest.fn() + const constructorHeaders = { + constructor: "header", + Accept: "application/set-from-constructor", + } + const paramHeaders = { + params: "header", + Accept: "application/set-from-params", + } + const client = new DrupalClient(BASE_URL, { + fetcher: customFetch, + headers: constructorHeaders, + }) + + const url = "http://example.com" + + await client.fetch(url, { + headers: paramHeaders, + }) + + expect(customFetch).toHaveBeenLastCalledWith( + url, + expect.objectContaining({ + ...defaultInit, + headers: { + ...constructorHeaders, + ...paramHeaders, + }, + }) + ) + }) + + test("does not add Authorization header by default", async () => { + const fetcher = jest.fn() + const client = new DrupalClient(BASE_URL, { + auth: authHeader, + fetcher, + }) + + await client.fetch(mockUrl) + + expect(fetcher).toHaveBeenLastCalledWith( + mockUrl, + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.anything(), + }), + }) + ) + }) + + test("optionally adds Authorization header from constructor", async () => { + const fetcher = jest.fn() + const client = new DrupalClient(BASE_URL, { + auth: authHeader, + fetcher, + }) + + await client.fetch(mockUrl, { withAuth: true }) + + expect(fetcher).toHaveBeenLastCalledWith( + mockUrl, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: authHeader, + }), + }) + ) + }) + + test("optionally adds Authorization header from init", async () => { + const fetcher = jest.fn() + const client = new DrupalClient(BASE_URL, { + fetcher, + }) + + await client.fetch(mockUrl, { withAuth: authHeader }) + + expect(fetcher).toHaveBeenLastCalledWith( + mockUrl, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: authHeader, + }), + }) + ) + }) +}) + +describe("getAccessToken()", () => { + const accessToken = mocks.auth.accessToken + const clientIdSecret = mocks.auth.clientIdSecret + + test("uses the long-lived access token from constructor", async () => { + const longLivedAccessToken: AccessToken = { + ...accessToken, + expires_in: 360000, + } + const client = new DrupalClient(BASE_URL, { + accessToken: longLivedAccessToken, + }) + const fetchSpy = spyOnFetch({ + responseBody: { + ...accessToken, + access_token: "not-used", + }, + }) + + const token = await client.getAccessToken({ + clientId: "", + clientSecret: "", + scope: undefined, + }) + expect(fetchSpy).toHaveBeenCalledTimes(0) + expect(token).toBe(longLivedAccessToken) + }) + + test("throws if auth is not configured", async () => { + const fetchSpy = spyOnFetch({ + responseBody: accessToken, + }) + + const client = new DrupalClient(BASE_URL) + + await expect( + // @ts-ignore + client.getAccessToken({ clientId: clientIdSecret.clientId }) + ).rejects.toThrow( + "auth is not configured. See https://next-drupal.org/docs/client/auth" + ) + expect(fetchSpy).toHaveBeenCalledTimes(0) + }) + + test("throws if auth is not ClientIdSecret", async () => { + const fetchSpy = spyOnFetch({ + responseBody: accessToken, + }) + + const client = new DrupalClient(BASE_URL, { + auth: mocks.auth.basicAuth, + withAuth: true, + }) + + await expect( + // @ts-ignore + client.getAccessToken() + ).rejects.toThrow( + "'clientId' and 'clientSecret' required. See https://next-drupal.org/docs/client/auth" + ) + expect(fetchSpy).toHaveBeenCalledTimes(0) + }) + + test("fetches an access token", async () => { + spyOnFetch({ + responseBody: accessToken, + }) + + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { + auth: clientIdSecret, + debug: true, + logger, + }) + + const token = await client.getAccessToken() + expect(token).toEqual(accessToken) + expect(logger.debug).toHaveBeenCalledWith("Fetching new access token.") + }) + + test("re-uses access token", async () => { + spyOnFetchOnce({ + responseBody: accessToken, + }) + const fetchSpy = spyOnFetchOnce({ + responseBody: { + ...accessToken, + access_token: "differentAccessToken", + expires_in: 1800, + }, + }) + + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { + auth: clientIdSecret, + debug: true, + logger, + }) + + const token1 = await client.getAccessToken() + const token2 = await client.getAccessToken() + expect(token1).toEqual(accessToken) + expect(token1).toEqual(token2) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using existing access token." + ) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) +}) + +describe("getAuthorizationHeader()", () => { + const accessToken = mocks.auth.accessToken + const basicAuth = mocks.auth.basicAuth + const basicAuthHeader = `Basic ${Buffer.from( + `${basicAuth.username}:${basicAuth.password}` + ).toString("base64")}` + const clientIdSecret = mocks.auth.clientIdSecret + const authCallback = mocks.auth.callback + const authHeader = mocks.auth.customAuthenticationHeader + + test("returns Basic Auth", async () => { + const auth: DrupalClientAuth = basicAuth + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { + auth: "is not used", + debug: true, + logger, + }) + + const header = await client.getAuthorizationHeader(auth) + + expect(header).toBe(basicAuthHeader) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using basic authorization header." + ) + }) + + test("returns Client Id/Secret", async () => { + const auth: DrupalClientAuth = clientIdSecret + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { + auth: "is not used", + debug: true, + logger, + }) + jest + .spyOn(client, "getAccessToken") + .mockImplementation(async () => accessToken) + + const header = await client.getAuthorizationHeader(auth) + + expect(header).toBe(`Bearer ${accessToken.access_token}`) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using access token authorization header retrieved from Client Id/Secret." + ) + }) + + test("returns Access Token", async () => { + const auth: DrupalClientAuth = accessToken + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { + auth: "is not used", + debug: true, + logger, + }) + + const header = await client.getAuthorizationHeader(auth) + + expect(header).toBe(`${auth.token_type} ${auth.access_token}`) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using access token authorization header." + ) + }) + + test("returns auth header", async () => { + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { + auth: "is not used", + debug: true, + logger, + }) + + const header = await client.getAuthorizationHeader(authHeader) + + expect(header).toBe(authHeader) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using custom authorization header." + ) + }) + + test("returns result of auth callback", async () => { + const auth: DrupalClientAuth = jest.fn(authCallback) + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { + auth: "is not used", + debug: true, + logger, + }) + + const header = await client.getAuthorizationHeader(auth) + + expect(header).toBe(authCallback()) + expect(auth).toBeCalledTimes(1) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using custom authorization callback." + ) + }) + + test("throws an error if auth is undefined", async () => { + const auth = undefined + const client = new DrupalClient(BASE_URL, { + auth: "is not used", + }) + + await expect(client.getAuthorizationHeader(auth)).rejects.toThrow( + "auth is not configured. See https://next-drupal.org/docs/client/auth" + ) + }) + + test("throws an error if auth is unrecognized", async () => { + const auth = { + username: "admin", + token_type: "Bearer", + } + const client = new DrupalClient(BASE_URL, { + auth: "is not used", + }) + + // @ts-ignore + await expect(client.getAuthorizationHeader(auth)).rejects.toThrow( + "auth is not configured. See https://next-drupal.org/docs/client/auth" + ) + }) +}) diff --git a/packages/next-drupal/tests/DrupalClient/pages-router-methods.test.ts b/packages/next-drupal/tests/DrupalClient/pages-router-methods.test.ts index 5751e69a..e9c2a587 100644 --- a/packages/next-drupal/tests/DrupalClient/pages-router-methods.test.ts +++ b/packages/next-drupal/tests/DrupalClient/pages-router-methods.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, jest, test } from "@jest/globals" -import { GetStaticPropsContext } from "next" -import { DrupalClient } from "../../src" -import { BASE_URL, mocks, spyOnFetch } from "../utils" +import { GetStaticPropsContext, NextApiRequest, NextApiResponse } from "next" +import { DRAFT_DATA_COOKIE_NAME, DrupalClient } from "../../src" +import { BASE_URL, mockLogger, mocks, spyOnFetch } from "../utils" import type { DrupalNode, JsonApiResourceWithPath } from "../../src" afterEach(() => { @@ -1174,6 +1174,199 @@ describe("getStaticPathsFromContext()", () => { }) }) +describe("preview()", () => { + // Get values from our mocked request. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { slug, resourceVersion, plugin, secret, ...draftData } = + new NextApiRequest().query + const dataCookie = `${DRAFT_DATA_COOKIE_NAME}=${encodeURIComponent( + JSON.stringify({ slug, resourceVersion, ...draftData }) + )}; Path=/; HttpOnly; SameSite=None; Secure` + const validationPayload = { + slug, + maxAge: 30, + } + + test("turns on preview mode and clears preview data", async () => { + const request = new NextApiRequest() + const response = new NextApiResponse() + const client = new DrupalClient(BASE_URL) + spyOnFetch({ responseBody: validationPayload }) + + await client.preview(request, response) + + expect(response.clearPreviewData).toBeCalledTimes(1) + expect(response.setPreviewData).toBeCalledWith({ + resourceVersion, + plugin, + ...validationPayload, + }) + }) + + test("does not enable preview mode if validation fails", async () => { + const logger = mockLogger() + const request = new NextApiRequest() + const response = new NextApiResponse() + const client = new DrupalClient(BASE_URL, { debug: true, logger }) + const status = 403 + const message = "mock fail" + spyOnFetch({ + responseBody: { message }, + status, + headers: { + "Content-Type": "application/json", + }, + }) + + await client.preview(request, response) + + expect(logger.debug).toBeCalledWith( + `Draft url validation error: ${message}` + ) + expect(response.setPreviewData).toBeCalledTimes(0) + expect(response.statusCode).toBe(status) + expect(response.json).toBeCalledWith({ message }) + }) + + test("does not turn on draft mode by default", async () => { + const request = new NextApiRequest() + const response = new NextApiResponse() + const client = new DrupalClient(BASE_URL) + spyOnFetch({ responseBody: validationPayload }) + + await client.preview(request, response) + + expect(response.setDraftMode).toBeCalledTimes(0) + + // Also check for no draft data cookie. + const cookies = response.getHeader("Set-Cookie") + expect(cookies[cookies.length - 1]).not.toBe(dataCookie) + }) + + test("optionally turns on draft mode", async () => { + const request = new NextApiRequest() + const response = new NextApiResponse() + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { + debug: true, + logger, + }) + spyOnFetch({ responseBody: validationPayload }) + + const options = { enable: true } + await client.preview(request, response, options) + + expect(response.setDraftMode).toBeCalledWith(options) + + // Also check for draft data cookie. + const cookies = response.getHeader("Set-Cookie") + expect(cookies[cookies.length - 1]).toBe(dataCookie) + + expect(logger.debug).toHaveBeenLastCalledWith("Draft mode enabled.") + }) + + test("updates preview mode cookie’s sameSite flag", async () => { + const request = new NextApiRequest() + const response = new NextApiResponse() + const client = new DrupalClient(BASE_URL) + spyOnFetch({ responseBody: validationPayload }) + + // Our mock response.setPreviewData() does not set a cookie, so we set one. + const previewCookie = + "__next_preview_data=secret-data; Path=/; HttpOnly; SameSite=Lax" + response.setHeader("Set-Cookie", [ + previewCookie, + ...response.getHeader("Set-Cookie"), + ]) + + const cookies = response.getHeader("Set-Cookie") + cookies[0] = cookies[0].replace("SameSite=Lax", "SameSite=None; Secure") + + await client.preview(request, response) + + expect(response.getHeader).toHaveBeenLastCalledWith("Set-Cookie") + expect(response.setHeader).toHaveBeenLastCalledWith("Set-Cookie", cookies) + expect(response.getHeader("Set-Cookie")).toStrictEqual(cookies) + }) + + test("redirects to the slug path", async () => { + const request = new NextApiRequest() + const response = new NextApiResponse() + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { debug: true, logger }) + spyOnFetch({ responseBody: validationPayload }) + + await client.preview(request, response) + + expect(response.setPreviewData).toBeCalledWith({ + resourceVersion, + plugin, + ...validationPayload, + }) + expect(response.writeHead).toBeCalledWith(307, { Location: slug }) + expect(logger.debug).toHaveBeenLastCalledWith("Preview mode enabled.") + }) + + test("returns a 422 response on error", async () => { + const request = new NextApiRequest() + const response = new NextApiResponse() + const logger = mockLogger() + const client = new DrupalClient(BASE_URL, { debug: true, logger }) + const message = "mock internal error" + response.clearPreviewData = jest.fn(() => { + throw new Error(message) + }) + + await client.preview(request, response) + + expect(logger.debug).toHaveBeenLastCalledWith(`Preview failed: ${message}`) + expect(response.status).toBeCalledWith(422) + expect(response.end).toHaveBeenCalled() + }) +}) + +describe("previewDisable()", () => { + test("clears preview data", async () => { + const request = new NextApiRequest() + const response = new NextApiResponse() + const client = new DrupalClient(BASE_URL) + + await client.previewDisable(request, response) + expect(response.clearPreviewData).toBeCalledTimes(1) + }) + + test("disables draft mode", async () => { + const request = new NextApiRequest() + const response = new NextApiResponse() + const client = new DrupalClient(BASE_URL) + + await client.previewDisable(request, response) + expect(response.setDraftMode).toBeCalledWith({ enable: false }) + }) + + test("deletes the draft cookie", async () => { + const request = new NextApiRequest() + const response = new NextApiResponse() + const client = new DrupalClient(BASE_URL) + + await client.previewDisable(request, response) + const cookies = response.getHeader("Set-Cookie") + expect(cookies[cookies.length - 1]).toBe( + `${DRAFT_DATA_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=None; Secure` + ) + }) + + test('redirects to "/"', async () => { + const request = new NextApiRequest() + const response = new NextApiResponse() + const client = new DrupalClient(BASE_URL) + + await client.previewDisable(request, response) + expect(response.writeHead).toBeCalledWith(307, { Location: "/" }) + expect(response.end).toBeCalled() + }) +}) + describe("translatePathFromContext()", () => { test("translates a path", async () => { const client = new DrupalClient(BASE_URL) diff --git a/packages/next-drupal/tests/DrupalClient/fetch-related-methods.test.ts b/packages/next-drupal/tests/DrupalClient/resource-methods.test.ts similarity index 73% rename from packages/next-drupal/tests/DrupalClient/fetch-related-methods.test.ts rename to packages/next-drupal/tests/DrupalClient/resource-methods.test.ts index 104dbdd5..c1494562 100644 --- a/packages/next-drupal/tests/DrupalClient/fetch-related-methods.test.ts +++ b/packages/next-drupal/tests/DrupalClient/resource-methods.test.ts @@ -1,343 +1,14 @@ import { afterEach, describe, expect, jest, test } from "@jest/globals" import { DrupalClient } from "../../src" -import { - BASE_URL, - mockLogger, - mocks, - spyOnFetch, - spyOnFetchOnce, -} from "../utils" -import type { - AccessToken, - DrupalClientAuth, - DrupalNode, - DrupalSearchApiJsonApiResponse, -} from "../../src" +import { BASE_URL, mocks, spyOnFetch } from "../utils" +import type { DrupalNode, DrupalSearchApiJsonApiResponse } from "../../src" afterEach(() => { jest.restoreAllMocks() }) -describe("fetch()", () => { - test("allows fetching custom url", async () => { - const client = new DrupalClient(BASE_URL) - const url = client.buildUrl( - "/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053" - ) - - const response = await client.fetch(url.toString()) - expect(response.headers.get("content-type")).toEqual( - "application/vnd.api+json" - ) - const json = await response.json() - expect(json).toMatchSnapshot() - }) - - test("allows for custom fetcher", async () => { - const customFetch = jest.fn() - - const client = new DrupalClient(BASE_URL, { - fetcher: customFetch, - }) - const url = client.buildUrl("/jsonapi").toString() - - await client.fetch(url) - expect(customFetch).toHaveBeenCalledWith( - url, - expect.objectContaining({ - headers: { - "Content-Type": "application/vnd.api+json", - Accept: "application/vnd.api+json", - }, - }) - ) - - await client.fetch(url, { - headers: { - foo: "bar", - }, - }) - expect(customFetch).toHaveBeenLastCalledWith( - url, - expect.objectContaining({ - headers: { - Accept: "application/vnd.api+json", - "Content-Type": "application/vnd.api+json", - foo: "bar", - }, - }) - ) - }) - - describe("authentication", () => { - const clientIdSecret = mocks.auth.clientIdSecret - - test("throws an error if withAuth is called when auth is not configured", async () => { - const client = new DrupalClient(BASE_URL) - - const url = client.buildUrl("/jsonapi") - - await expect( - client.fetch(url.toString(), { - withAuth: true, - }) - ).rejects.toThrow("auth is not configured.") - }) - - test("accepts username and password", async () => { - const customFetch = jest.fn() - - const client = new DrupalClient(BASE_URL, { - auth: { - username: "admin", - password: "password", - }, - fetcher: customFetch, - }) - const url = client.buildUrl("/jsonapi").toString() - - await client.fetch(url, { withAuth: true }) - expect(customFetch).toHaveBeenCalledWith( - url, - expect.objectContaining({ - headers: { - "Content-Type": "application/vnd.api+json", - Accept: "application/vnd.api+json", - Authorization: "Basic YWRtaW46cGFzc3dvcmQ=", - }, - }) - ) - }) - - test("accepts callback", async () => { - const customAuth = jest - .fn() - .mockReturnValue( - "Basic YXJzaGFkQG5leHQtZHJ1cGFsLm9yZzphYmMxMjM=" - ) as DrupalClientAuth - const customFetch = jest.fn() - - const client = new DrupalClient(BASE_URL, { - auth: customAuth, - fetcher: customFetch, - }) - const url = client.buildUrl("/jsonapi").toString() - - await client.fetch(url, { withAuth: true }) - expect(customFetch).toHaveBeenCalledWith( - url, - expect.objectContaining({ - headers: { - "Content-Type": "application/vnd.api+json", - Accept: "application/vnd.api+json", - Authorization: "Basic YXJzaGFkQG5leHQtZHJ1cGFsLm9yZzphYmMxMjM=", - }, - }) - ) - }) - - test("accepts clientId and clientSecret", async () => { - const client = new DrupalClient(BASE_URL, { - auth: clientIdSecret, - }) - const fetchSpy = spyOnFetch() - - const basic = Buffer.from( - `${clientIdSecret.clientId}:${clientIdSecret.clientSecret}` - ).toString("base64") - - await client.fetch("http://example.com", { withAuth: true }) - expect(fetchSpy).toHaveBeenNthCalledWith( - 1, - `${BASE_URL}/oauth/token`, - expect.objectContaining({ - headers: { - Accept: "application/json", - Authorization: `Basic ${basic}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - }) - ) - }) - - test("accepts custom auth url", async () => { - const client = new DrupalClient(BASE_URL, { - auth: { - ...clientIdSecret, - url: "/custom/oauth", - }, - }) - const fetchSpy = spyOnFetch() - - await client.fetch("http://example.com", { withAuth: true }) - expect(fetchSpy).toHaveBeenNthCalledWith( - 1, - `${BASE_URL}/custom/oauth`, - expect.anything() - ) - }) - }) - - describe("headers", () => { - // TODO: Are these duplicates of getters-setters/headers tests? - test("allows setting custom headers", async () => { - const customFetch = jest.fn() - const client = new DrupalClient(BASE_URL, { - fetcher: customFetch, - }) - client.headers = { - foo: "bar", - } - - const url = "http://example.com" - - await client.fetch(url) - expect(customFetch).toHaveBeenCalledWith( - url, - expect.objectContaining({ - headers: { foo: "bar" }, - }) - ) - }) - - test("allows setting custom headers with custom auth", async () => { - const customFetch = jest.fn() - const client = new DrupalClient(BASE_URL, { - fetcher: customFetch, - headers: { - foo: "bar", - }, - auth: jest - .fn() - .mockReturnValue( - "Basic YXJzaGFkQG5leHQtZHJ1cGFsLm9yZzphYmMxMjM=" - ) as DrupalClientAuth, - }) - - const url = "http://example.com" - - await client.fetch(url, { withAuth: true }) - - expect(customFetch).toHaveBeenCalledWith( - url, - expect.objectContaining({ - headers: { - foo: "bar", - Authorization: "Basic YXJzaGFkQG5leHQtZHJ1cGFsLm9yZzphYmMxMjM=", - }, - }) - ) - }) - }) -}) - -describe("getAccessToken()", () => { - const accessToken = mocks.auth.accessToken - const clientIdSecret = mocks.auth.clientIdSecret - - test("uses the long-lived access token from constructor", async () => { - const longLivedAccessToken: AccessToken = { - ...accessToken, - expires_in: 360000, - } - const client = new DrupalClient(BASE_URL, { - accessToken: longLivedAccessToken, - }) - const fetchSpy = spyOnFetch({ - responseBody: { - ...accessToken, - access_token: "not-used", - }, - }) - - const token = await client.getAccessToken({ - clientId: "", - clientSecret: "", - scope: undefined, - }) - expect(fetchSpy).toHaveBeenCalledTimes(0) - expect(token).toBe(longLivedAccessToken) - }) - - test("throws if auth is not configured", async () => { - const fetchSpy = spyOnFetch({ - responseBody: accessToken, - }) - - const client = new DrupalClient(BASE_URL) - - await expect( - // @ts-ignore - client.getAccessToken({ clientId: clientIdSecret.clientId }) - ).rejects.toThrow( - "auth is not configured. See https://next-drupal.org/docs/client/auth" - ) - expect(fetchSpy).toHaveBeenCalledTimes(0) - }) - - test("throws if auth is not ClientIdSecret", async () => { - const fetchSpy = spyOnFetch({ - responseBody: accessToken, - }) - - const client = new DrupalClient(BASE_URL, { - auth: mocks.auth.basicAuth, - withAuth: true, - }) - - await expect( - // @ts-ignore - client.getAccessToken() - ).rejects.toThrow( - "'clientId' and 'clientSecret' required. See https://next-drupal.org/docs/client/auth" - ) - expect(fetchSpy).toHaveBeenCalledTimes(0) - }) - - test("fetches an access token", async () => { - spyOnFetch({ - responseBody: accessToken, - }) - - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - auth: clientIdSecret, - debug: true, - logger, - }) - - const token = await client.getAccessToken() - expect(token).toEqual(accessToken) - expect(logger.debug).toHaveBeenCalledWith("Fetching new access token.") - }) - - test("re-uses access token", async () => { - spyOnFetchOnce({ - responseBody: accessToken, - }) - const fetchSpy = spyOnFetchOnce({ - responseBody: { - ...accessToken, - access_token: "differentAccessToken", - expires_in: 1800, - }, - }) - - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - auth: clientIdSecret, - debug: true, - logger, - }) - - const token1 = await client.getAccessToken() - const token2 = await client.getAccessToken() - expect(token1).toEqual(token2) - expect(logger.debug).toHaveBeenLastCalledWith( - "Using existing access token." - ) - expect(fetchSpy).toHaveBeenCalledTimes(1) - }) +describe("buildMenuTree()", () => { + test.todo("add tests") }) describe("getEntryForResourceType()", () => { diff --git a/packages/next-drupal/tests/utils/mocks/data.ts b/packages/next-drupal/tests/utils/mocks/data.ts index d1caf896..dd539705 100644 --- a/packages/next-drupal/tests/utils/mocks/data.ts +++ b/packages/next-drupal/tests/utils/mocks/data.ts @@ -1,5 +1,4 @@ import type { - DrupalClientAuth, DrupalClientAuthAccessToken, DrupalClientAuthClientIdSecret, DrupalClientAuthUsernamePassword,