Skip to content

Commit bcf4bb9

Browse files
authored
Merge pull request #272 from NHSDigital/feature/CCM-8308_logout-warning
CCM-8308: Add logout warning modal
2 parents 7b0da7f + 35c9fce commit bcf4bb9

File tree

54 files changed

+1088
-69
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1088
-69
lines changed

.tool-versions

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ pre-commit 3.6.0
44
terraform 1.9.2
55
vale 3.6.0
66
tfsec 1.28.10
7-
nodejs 20.13.1
7+
nodejs 20.18.2
88

99
# ==============================================================================
1010
# The section below is reserved for Docker image versions.

frontend/jest.setup.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// https://nextjs.org/docs/app/building-your-application/testing/jest#optional-extend-jest-with-custom-matchers
22
import '@testing-library/jest-dom';
33
import { TextEncoder, TextDecoder } from 'node:util';
4+
import { createMocks } from 'react-idle-timer';
5+
46
/*
57
* A polyfill for fetch API which includes the Request object
68
* this helps solve the issue of the test throwing an error if the `getAmplifyBackendClient` is not mocked out.
@@ -9,3 +11,5 @@ import { TextEncoder, TextDecoder } from 'node:util';
911
import 'whatwg-fetch';
1012

1113
Object.assign(global, { TextDecoder, TextEncoder });
14+
15+
createMocks();

frontend/next.config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,23 @@ const nextConfig = (phase) => {
3636
basePath: false,
3737
permanent: false,
3838
},
39+
{
40+
source: `${basePath}/auth/inactive`,
41+
destination: '/auth/inactive',
42+
permanent: false,
43+
basePath: false,
44+
},
3945
];
4046
},
4147

4248
async rewrites() {
4349
if (includeAuthPages) {
4450
return [
51+
{
52+
source: '/auth/inactive',
53+
destination: `http://${domain}${basePath}/auth/idle`,
54+
basePath: false,
55+
},
4556
{
4657
source: '/auth/signout',
4758
destination: `http://${domain}${basePath}/auth/signout`,

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"path": "^0.12.7",
3737
"react": "^19.0.0",
3838
"react-dom": "^19.0.0",
39+
"react-idle-timer": "^5.7.2",
3940
"zod": "^3.23.8"
4041
},
4142
"devDependencies": {
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { render, fireEvent, act } from '@testing-library/react';
2+
import { LogoutWarningModal } from '@molecules/LogoutWarningModal/LogoutWarningModal';
3+
import { useAuthenticator, type UseAuthenticator } from '@aws-amplify/ui-react';
4+
import { useRouter, usePathname } from 'next/navigation';
5+
import { mockDeep } from 'jest-mock-extended';
6+
7+
jest.mock('next/navigation');
8+
jest.mock('aws-amplify/auth');
9+
jest.mock('@aws-amplify/ui-react');
10+
11+
const useAuthenticatorMock = jest.mocked(useAuthenticator);
12+
13+
const routerPushMock: jest.Mock = jest.fn();
14+
15+
const dialogCloseMock = jest.fn(function mock(this: HTMLDialogElement) {
16+
this.open = false;
17+
});
18+
19+
const dialogOpenMock = jest.fn(function mock(this: HTMLDialogElement) {
20+
this.open = true;
21+
});
22+
23+
describe('LogoutWarningModal', () => {
24+
beforeAll(() => {
25+
jest.resetAllMocks();
26+
27+
// Polyfill dialog actions since JSDOM does not support it
28+
// https://github.com/jsdom/jsdom/issues/3294
29+
HTMLDialogElement.prototype.showModal = dialogOpenMock;
30+
31+
HTMLDialogElement.prototype.close = dialogCloseMock;
32+
33+
useAuthenticatorMock.mockImplementation((cb) => {
34+
const context = mockDeep<UseAuthenticator>({
35+
authStatus: 'authenticated',
36+
});
37+
38+
if (cb) {
39+
cb(context);
40+
}
41+
42+
return context;
43+
});
44+
45+
(useRouter as jest.Mock).mockReturnValue({
46+
push: routerPushMock,
47+
});
48+
49+
(usePathname as jest.Mock).mockReturnValue('testing');
50+
51+
jest.useFakeTimers();
52+
});
53+
54+
test('should match snapshot', async () => {
55+
const container = render(
56+
<LogoutWarningModal
57+
promptBeforeLogoutSeconds={120} // 2 minutes
58+
logoutInSeconds={900} // 15 minutes
59+
/>
60+
);
61+
62+
expect(container.asFragment()).toMatchSnapshot();
63+
});
64+
65+
test('should stay closed if not authenticated', () => {
66+
useAuthenticatorMock.mockReturnValueOnce(
67+
mockDeep<UseAuthenticator>({
68+
authStatus: 'unauthenticated',
69+
})
70+
);
71+
72+
render(
73+
<LogoutWarningModal
74+
promptBeforeLogoutSeconds={120} // 2 minutes
75+
logoutInSeconds={900} // 15 minutes
76+
/>
77+
);
78+
79+
act(() => {
80+
// advance timers by 15 minutes
81+
jest.advanceTimersByTime(60 * 15 * 1000);
82+
fireEvent.focus(document);
83+
});
84+
85+
expect(routerPushMock).not.toHaveBeenCalled();
86+
87+
expect(dialogOpenMock).not.toHaveBeenCalled();
88+
89+
expect(dialogCloseMock).toHaveBeenCalledTimes(1);
90+
});
91+
92+
test('should show modal after prompt timeout', async () => {
93+
render(
94+
<LogoutWarningModal
95+
promptBeforeLogoutSeconds={120} // 2 minutes
96+
logoutInSeconds={900} // 15 minutes
97+
/>
98+
);
99+
100+
// advance timers by 12 minutes
101+
jest.advanceTimersByTime(60 * 12 * 1000);
102+
103+
expect(dialogOpenMock).not.toHaveBeenCalled();
104+
105+
act(() => {
106+
// advance timers by 1 minute
107+
jest.advanceTimersByTime(60 * 1 * 1000);
108+
fireEvent.focus(document);
109+
});
110+
111+
expect(dialogOpenMock).toHaveBeenCalledTimes(1);
112+
});
113+
114+
test('should update modal header to reflect seconds before logout', () => {
115+
const { getByTestId } = render(
116+
<LogoutWarningModal
117+
promptBeforeLogoutSeconds={120} // 2 minutes
118+
logoutInSeconds={900} // 15 minutes
119+
/>
120+
);
121+
122+
act(() => {
123+
// advance timers by 14 minutes
124+
jest.advanceTimersByTime(60 * 14 * 1000);
125+
fireEvent.focus(document);
126+
});
127+
128+
const events = [
129+
{
130+
timeRemaining: 25,
131+
advanceTimersBySeconds: 35,
132+
},
133+
{
134+
timeRemaining: 20,
135+
advanceTimersBySeconds: 5,
136+
},
137+
{
138+
timeRemaining: 10,
139+
advanceTimersBySeconds: 10,
140+
},
141+
{
142+
timeRemaining: 5,
143+
advanceTimersBySeconds: 5,
144+
},
145+
];
146+
147+
for (const { timeRemaining, advanceTimersBySeconds } of events) {
148+
act(() => {
149+
// advance timers by timeRemaining
150+
jest.advanceTimersByTime(advanceTimersBySeconds * 1000);
151+
fireEvent.focus(document);
152+
});
153+
154+
expect(getByTestId('modal-header').innerHTML).toEqual(
155+
`For security reasons, you'll be signed out in ${timeRemaining} seconds.`
156+
);
157+
}
158+
});
159+
160+
test('should close modal when user clicks "Stay signed in"', () => {
161+
const { getByTestId } = render(
162+
<LogoutWarningModal
163+
promptBeforeLogoutSeconds={120} // 2 minutes
164+
logoutInSeconds={900} // 15 minutes
165+
/>
166+
);
167+
168+
act(() => {
169+
// advance timers by 13 minutes
170+
jest.advanceTimersByTime(60 * 13 * 1000);
171+
fireEvent.focus(document);
172+
});
173+
174+
fireEvent.click(getByTestId('modal-sign-in'));
175+
176+
expect(dialogCloseMock).toHaveBeenCalledTimes(2);
177+
});
178+
179+
test('should redirect when user clicks "Sign out"', async () => {
180+
const { getByTestId } = render(
181+
<LogoutWarningModal
182+
promptBeforeLogoutSeconds={120} // 2 minutes
183+
logoutInSeconds={900} // 15 minutes
184+
/>
185+
);
186+
187+
act(() => {
188+
// advance timers by 13 minutes
189+
jest.advanceTimersByTime(60 * 13 * 1000);
190+
fireEvent.focus(document);
191+
});
192+
193+
const clicked = fireEvent.click(getByTestId('modal-sign-out'));
194+
195+
expect(clicked).toBe(true);
196+
});
197+
198+
test('should reset remaining time to initial value when user clicks "stay signed in"', () => {
199+
const { getByTestId } = render(
200+
<LogoutWarningModal
201+
promptBeforeLogoutSeconds={120} // 2 minutes
202+
logoutInSeconds={900} // 15 minutes
203+
/>
204+
);
205+
206+
act(() => {
207+
// advance timers by 14 minutes
208+
jest.advanceTimersByTime(60 * 14 * 1000);
209+
fireEvent.focus(document);
210+
});
211+
212+
act(() => {
213+
// advance timers by 32 seconds
214+
jest.advanceTimersByTime(35_000);
215+
fireEvent.focus(document);
216+
});
217+
218+
expect(getByTestId('modal-header').innerHTML).toEqual(
219+
`For security reasons, you'll be signed out in 25 seconds.`
220+
);
221+
222+
const clicked = fireEvent.click(getByTestId('modal-sign-in'));
223+
224+
expect(clicked).toBe(true);
225+
226+
act(() => {
227+
// advance timers by 13 minutes
228+
jest.advanceTimersByTime(60 * 13 * 1000);
229+
fireEvent.focus(document);
230+
});
231+
232+
expect(getByTestId('modal-header').innerHTML).toEqual(
233+
`For security reasons, you'll be signed out in 2 minutes.`
234+
);
235+
});
236+
237+
test('should redirect when user does not interact after specified time', () => {
238+
render(
239+
<LogoutWarningModal
240+
promptBeforeLogoutSeconds={120} // 2 minutes
241+
logoutInSeconds={900} // 15 minutes
242+
/>
243+
);
244+
245+
act(() => {
246+
// set system time to 15 minutes into the future
247+
jest.setSystemTime(Date.now() + 60 * 15 * 1000);
248+
249+
// advance timers by 15 minutes
250+
jest.advanceTimersByTime(60 * 15 * 1000);
251+
252+
fireEvent.focus(document);
253+
});
254+
255+
expect(routerPushMock).toHaveBeenCalledWith(
256+
'/auth/inactive?redirect=%2Ftemplates%2Ftesting'
257+
);
258+
259+
expect(dialogCloseMock).toHaveBeenCalledTimes(1);
260+
});
261+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`LogoutWarningModal should match snapshot 1`] = `
4+
<DocumentFragment>
5+
<dialog
6+
aria-labelledby="modal-heading"
7+
aria-live="assertive"
8+
aria-modal="true"
9+
class="modal"
10+
role="alertdialog"
11+
>
12+
<div
13+
class="modal__content"
14+
>
15+
<div
16+
class="modal__heading"
17+
data-testid="modal-header"
18+
id="modal-heading"
19+
>
20+
For security reasons, you'll be signed out in 2 minutes.
21+
</div>
22+
<div
23+
class="modal__body"
24+
>
25+
<p>
26+
If you're signed out, any unsaved changes will be lost.
27+
</p>
28+
</div>
29+
<div
30+
class="modal__footer"
31+
>
32+
<button
33+
aria-disabled="false"
34+
class="nhsuk-button signIn"
35+
data-testid="modal-sign-in"
36+
type="submit"
37+
>
38+
Stay signed in
39+
</button>
40+
<div
41+
class="signOut"
42+
>
43+
<a
44+
class="nhsuk-link"
45+
data-testid="modal-sign-out"
46+
href="/auth/signout"
47+
>
48+
Sign out
49+
</a>
50+
</div>
51+
</div>
52+
</div>
53+
</dialog>
54+
</DocumentFragment>
55+
`;

0 commit comments

Comments
 (0)