diff --git a/package.json b/package.json index 37d4d72a4..2229124c4 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,8 @@ "vite-plugin-html": "3.2.2", "vite-plugin-node-polyfills": "0.22.0", "vite-tsconfig-paths": "4.3.2", - "vitest": "1.6.0" + "vitest": "1.6.0", + "vitest-fetch-mock": "0.2.2" }, "npmpackagejsonlint": { "rules": { diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 000000000..de0dd38e1 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1 @@ +import "vitest-fetch-mock"; diff --git a/src/juju/jimm/api.ts b/src/juju/jimm/api.ts new file mode 100644 index 000000000..7706997f0 --- /dev/null +++ b/src/juju/jimm/api.ts @@ -0,0 +1,5 @@ +export const endpoints = { + login: "/auth/login", + logout: "/auth/logout", + whoami: "/auth/whoami", +}; diff --git a/src/juju/jimm/thunks.test.ts b/src/juju/jimm/thunks.test.ts new file mode 100644 index 000000000..54a69ae14 --- /dev/null +++ b/src/juju/jimm/thunks.test.ts @@ -0,0 +1,67 @@ +import { unwrapResult } from "@reduxjs/toolkit"; +import { vi } from "vitest"; + +import { endpoints } from "./api"; +import { logout, whoami } from "./thunks"; + +describe("thunks", () => { + beforeEach(() => { + fetchMock.resetMocks(); + }); + + it("logout", async () => { + const action = logout(); + await action(vi.fn(), vi.fn(), null); + expect(global.fetch).toHaveBeenCalledWith(endpoints.logout); + }); + + it("logout handles unsuccessful requests", async () => { + fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 }); + const action = logout(); + const response = await action(vi.fn(), vi.fn(), null); + expect("error" in response ? response.error.message : null).toBe( + "Unable to log out: non-success response", + ); + }); + + it("logout handles errors", async () => { + fetchMock.mockRejectedValue("404"); + const action = logout(); + const response = await action(vi.fn(), vi.fn(), null); + expect("error" in response ? response.error.message : null).toBe( + "Unable to log out: 404", + ); + }); + + it("whoami returns a user", async () => { + const action = whoami(); + await action(vi.fn(), vi.fn(), null); + expect(global.fetch).toHaveBeenCalledWith(endpoints.whoami); + }); + + it("whoami handles non-authenticated user", async () => { + fetchMock.mockResponseOnce(JSON.stringify({}), { status: 403 }); + const action = whoami(); + const response = await action(vi.fn(), vi.fn(), null); + expect(global.fetch).toHaveBeenCalledWith(endpoints.whoami); + expect(unwrapResult(response)).toBeNull(); + }); + + it("whoami unsuccessful requests", async () => { + fetchMock.mockResponse(JSON.stringify({}), { status: 500 }); + const action = whoami(); + const response = await action(vi.fn(), vi.fn(), null); + expect("error" in response ? response.error.message : null).toBe( + "Unable to get user details: non-success response", + ); + }); + + it("whoami handles errors", async () => { + fetchMock.mockRejectedValue("404"); + const action = whoami(); + const response = await action(vi.fn(), vi.fn(), null); + expect("error" in response ? response.error.message : null).toBe( + "Unable to get user details: 404", + ); + }); +}); diff --git a/src/juju/jimm/thunks.ts b/src/juju/jimm/thunks.ts new file mode 100644 index 000000000..9a3b51dfb --- /dev/null +++ b/src/juju/jimm/thunks.ts @@ -0,0 +1,51 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; + +import type { RootState } from "store/store"; +import { toErrorString } from "utils"; + +import { endpoints } from "./api"; + +/** + Log out of the JIMM API. +*/ +export const logout = createAsyncThunk< + void, + void, + { + state: RootState; + } +>("jimm/logout", async () => { + try { + const response = await fetch(endpoints.logout); + if (!response.ok) { + throw new Error("non-success response"); + } + } catch (error) { + throw new Error(`Unable to log out: ${toErrorString(error)}`); + } +}); + +/** + Get the authenticated user from the JIMM API. +*/ +export const whoami = createAsyncThunk< + void, + void, + { + state: RootState; + } +>("jimm/whoami", async () => { + try { + const response = await fetch(endpoints.whoami); + if (response.status === 403) { + // The user is not authenticated so return null instead of throwing an error. + return null; + } + if (!response.ok) { + throw new Error("non-success response"); + } + return await response.json(); + } catch (error) { + throw new Error(`Unable to get user details: ${toErrorString(error)}`); + } +}); diff --git a/src/store/middleware/check-auth.ts b/src/store/middleware/check-auth.ts index 69cbdb2de..a2fc8f948 100644 --- a/src/store/middleware/check-auth.ts +++ b/src/store/middleware/check-auth.ts @@ -5,6 +5,7 @@ import { isAction, type Middleware } from "redux"; +import * as jimmThunks from "juju/jimm/thunks"; import { actions as appActions, thunks as appThunks } from "store/app"; import { actions as generalActions } from "store/general"; import { isLoggedIn } from "store/general/selectors"; @@ -99,6 +100,12 @@ export const checkAuthMiddleware: Middleware< addControllerCloudRegion.fulfilled.type, addControllerCloudRegion.pending.type, addControllerCloudRegion.rejected.type, + jimmThunks.logout.fulfilled.type, + jimmThunks.logout.pending.type, + jimmThunks.logout.rejected.type, + jimmThunks.whoami.fulfilled.type, + jimmThunks.whoami.pending.type, + jimmThunks.whoami.rejected.type, ]; const state = getState(); diff --git a/src/testing/setup.ts b/src/testing/setup.ts index de89074e6..5f763849e 100644 --- a/src/testing/setup.ts +++ b/src/testing/setup.ts @@ -1,9 +1,14 @@ import "@testing-library/jest-dom/vitest"; import type { Window as HappyDOMWindow } from "happy-dom"; import { vi } from "vitest"; +import createFetchMock from "vitest-fetch-mock"; vi.mock("react-ga"); +const fetchMocker = createFetchMock(vi); +// sets globalThis.fetch and globalThis.fetchMock to our mocked version +fetchMocker.enableMocks(); + declare global { interface Window extends HappyDOMWindow {} // eslint-disable-next-line no-var diff --git a/yarn.lock b/yarn.lock index 8b8bf55c6..8ad080e4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3807,6 +3807,15 @@ __metadata: languageName: node linkType: hard +"cross-fetch@npm:^3.0.6": + version: 3.1.8 + resolution: "cross-fetch@npm:3.1.8" + dependencies: + node-fetch: "npm:^2.6.12" + checksum: 10c0/4c5e022ffe6abdf380faa6e2373c0c4ed7ef75e105c95c972b6f627c3f083170b6886f19fb488a7fa93971f4f69dcc890f122b0d97f0bf5f41ca1d9a8f58c8af + languageName: node + linkType: hard + "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" @@ -7582,6 +7591,7 @@ __metadata: vite-plugin-node-polyfills: "npm:0.22.0" vite-tsconfig-paths: "npm:4.3.2" vitest: "npm:1.6.0" + vitest-fetch-mock: "npm:0.2.2" yup: "npm:1.4.0" languageName: unknown linkType: soft @@ -8271,6 +8281,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.12": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 + languageName: node + linkType: hard + "node-gyp-build@npm:^4.3.0": version: 4.6.0 resolution: "node-gyp-build@npm:4.6.0" @@ -10935,6 +10959,13 @@ __metadata: languageName: node linkType: hard +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 + languageName: node + linkType: hard + "trim-newlines@npm:^3.0.0": version: 3.0.1 resolution: "trim-newlines@npm:3.0.1" @@ -11549,6 +11580,17 @@ __metadata: languageName: node linkType: hard +"vitest-fetch-mock@npm:0.2.2": + version: 0.2.2 + resolution: "vitest-fetch-mock@npm:0.2.2" + dependencies: + cross-fetch: "npm:^3.0.6" + peerDependencies: + vitest: ">=0.16.0" + checksum: 10c0/5c011274089301e2c21e794e79de6af2ad2884c1bf5784f79f11a06f93b8dc0284f007645ab145708bab86c810356eaafaf9de31fe9746f9084dcf28c20f85e9 + languageName: node + linkType: hard + "vitest@npm:1.6.0": version: 1.6.0 resolution: "vitest@npm:1.6.0" @@ -11615,6 +11657,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db + languageName: node + linkType: hard + "webidl-conversions@npm:^7.0.0": version: 7.0.0 resolution: "webidl-conversions@npm:7.0.0" @@ -11662,6 +11711,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.0.2": version: 1.0.2 resolution: "which-boxed-primitive@npm:1.0.2"