-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: handle oidc refresh and revoke
- Loading branch information
Showing
16 changed files
with
307 additions
and
49 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
import type { | ||
ListenerMiddlewareInstance, | ||
UnknownAction, | ||
EnhancedStore, | ||
StoreEnhancer, | ||
Tuple, | ||
} from "@reduxjs/toolkit"; | ||
import { | ||
createListenerMiddleware, | ||
configureStore, | ||
createSlice, | ||
} from "@reduxjs/toolkit"; | ||
import { waitFor } from "@testing-library/react"; | ||
import { vi } from "vitest"; | ||
|
||
import { thunks as appThunks } from "store/app"; | ||
import { actions as generalActions } from "store/general"; | ||
import type { RootState, AppDispatch } from "store/store"; | ||
import { rootStateFactory } from "testing/factories"; | ||
|
||
import { endpoints } from "./api"; | ||
import { | ||
addWhoamiListener, | ||
pollWhoamiStart, | ||
Label, | ||
pollWhoamiStop, | ||
} from "./listeners"; | ||
|
||
vi.mock("consts", () => { | ||
return { OIDC_POLL_INTERVAL: 1 }; | ||
}); | ||
|
||
vi.mock("store/general", async () => { | ||
const actual = await vi.importActual("store/general"); | ||
return { | ||
...actual, | ||
actions: { | ||
storeLoginError: vi.fn(), | ||
}, | ||
}; | ||
}); | ||
|
||
vi.mock("store/app/thunks", async () => { | ||
const actual = await vi.importActual("store/app/thunks"); | ||
return { | ||
...actual, | ||
logOut: vi.fn(), | ||
}; | ||
}); | ||
|
||
describe("listeners", () => { | ||
let listenerMiddleware: ListenerMiddlewareInstance< | ||
RootState, | ||
AppDispatch, | ||
unknown | ||
>; | ||
let store: EnhancedStore< | ||
RootState, | ||
UnknownAction, | ||
Tuple< | ||
[ | ||
StoreEnhancer<{ | ||
dispatch: AppDispatch; | ||
}>, | ||
StoreEnhancer, | ||
] | ||
> | ||
>; | ||
|
||
beforeEach(() => { | ||
fetchMock.resetMocks(); | ||
listenerMiddleware = createListenerMiddleware<RootState, AppDispatch>(); | ||
const slice = createSlice({ | ||
name: "root", | ||
initialState: rootStateFactory.withGeneralConfig().build(), | ||
reducers: {}, | ||
}); | ||
store = configureStore({ | ||
reducer: slice.reducer, | ||
middleware: (getDefaultMiddleware) => { | ||
const middleware = getDefaultMiddleware(); | ||
middleware.push(listenerMiddleware.middleware); | ||
return middleware; | ||
}, | ||
}); | ||
addWhoamiListener(listenerMiddleware.startListening); | ||
}); | ||
|
||
afterEach(() => { | ||
listenerMiddleware.clearListeners(); | ||
}); | ||
|
||
it("starts polling when start action is dispatched", async () => { | ||
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 200 }); | ||
expect(global.fetch).not.toHaveBeenCalled(); | ||
store.dispatch(pollWhoamiStart()); | ||
await waitFor(() => | ||
expect(global.fetch).toHaveBeenCalledWith(endpoints.whoami), | ||
); | ||
}); | ||
|
||
it("handles user logged out", async () => { | ||
vi.spyOn(store, "dispatch"); | ||
vi.spyOn(generalActions, "storeLoginError").mockReturnValue({ | ||
type: "general/storeLoginError", | ||
payload: { wsControllerURL: "", error: "" }, | ||
}); | ||
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 401 }); | ||
store.dispatch(pollWhoamiStart()); | ||
await waitFor(() => | ||
expect(generalActions.storeLoginError).toHaveBeenCalledWith({ | ||
error: Label.ERROR_LOGGED_OUT, | ||
wsControllerURL: "wss://controller.example.com", | ||
}), | ||
); | ||
await waitFor(() => expect(appThunks.logOut).toHaveBeenCalled()); | ||
}); | ||
|
||
it("handles errors", async () => { | ||
vi.spyOn(store, "dispatch"); | ||
vi.spyOn(generalActions, "storeLoginError").mockReturnValue({ | ||
type: "general/storeLoginError", | ||
payload: { wsControllerURL: "", error: "" }, | ||
}); | ||
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 }); | ||
store.dispatch(pollWhoamiStart()); | ||
await waitFor(() => | ||
expect(generalActions.storeLoginError).toHaveBeenCalledWith({ | ||
error: Label.ERROR_AUTHENTICATION, | ||
wsControllerURL: "wss://controller.example.com", | ||
}), | ||
); | ||
await waitFor(() => expect(appThunks.logOut).toHaveBeenCalled()); | ||
}); | ||
|
||
it("does not display an error when stopping the listener", async () => { | ||
vi.spyOn(store, "dispatch"); | ||
vi.spyOn(generalActions, "storeLoginError").mockReturnValue({ | ||
type: "general/storeLoginError", | ||
payload: { wsControllerURL: "", error: "" }, | ||
}); | ||
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 }); | ||
store.dispatch(pollWhoamiStart()); | ||
store.dispatch(pollWhoamiStop()); | ||
await waitFor(() => | ||
expect(generalActions.storeLoginError).not.toHaveBeenCalled(), | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
import type { ListenerMiddlewareInstance } from "@reduxjs/toolkit"; | ||
import { createAction, TaskAbortError } from "@reduxjs/toolkit"; | ||
|
||
import { OIDC_POLL_INTERVAL } from "consts"; | ||
import { thunks as appThunks } from "store/app"; | ||
import { actions as generalActions } from "store/general"; | ||
import { getWSControllerURL } from "store/general/selectors"; | ||
import type { RootState, AppDispatch } from "store/store"; | ||
import { toErrorString } from "utils"; | ||
|
||
import { endpoints } from "./api"; | ||
|
||
export enum Label { | ||
ERROR_AUTHENTICATION = "Authentication error.", | ||
ERROR_LOGGED_OUT = "You have been logged out.", | ||
} | ||
|
||
export const pollWhoamiStart = createAction("jimm/pollWhoami/start"); | ||
export const pollWhoamiStop = createAction("jimm/pollWhoami/stop"); | ||
|
||
export const addWhoamiListener = ( | ||
startListening: ListenerMiddlewareInstance< | ||
RootState, | ||
AppDispatch, | ||
unknown | ||
>["startListening"], | ||
) => { | ||
startListening({ | ||
actionCreator: pollWhoamiStart, | ||
effect: async (_action, listenerApi) => { | ||
listenerApi.unsubscribe(); | ||
const pollingTask = listenerApi.fork(async (forkApi) => { | ||
try { | ||
while (true) { | ||
await forkApi.delay(OIDC_POLL_INTERVAL); | ||
const response = await forkApi.pause(fetch(endpoints.whoami)); | ||
// Handle the user no longer logged in: | ||
if (response.status === 401 || response.status === 403) { | ||
throw new Error(Label.ERROR_LOGGED_OUT); | ||
} | ||
// Handle all other API errors: | ||
if (!response.ok) { | ||
throw new Error(Label.ERROR_AUTHENTICATION); | ||
} | ||
} | ||
} catch (error) { | ||
if (error instanceof TaskAbortError) { | ||
// Polling was aborted e.g. when clicking "log out". Don't display | ||
// this to the user. | ||
return; | ||
} | ||
const wsControllerURL = getWSControllerURL(listenerApi.getState()); | ||
if (wsControllerURL) { | ||
listenerApi.dispatch( | ||
generalActions.storeLoginError({ | ||
error: toErrorString(error), | ||
wsControllerURL, | ||
}), | ||
); | ||
} | ||
// If the user no longer has access then clean up state and display the login screen. | ||
await listenerApi.dispatch(appThunks.logOut()); | ||
} | ||
}); | ||
await listenerApi.condition(pollWhoamiStop.match); | ||
pollingTask.cancel(); | ||
}, | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.