Skip to content

Commit ab93894

Browse files
Michael Smitmikesmit
authored andcommitted
Add bearer token just to user-profile
partially adresses #2263 1. Adds a new hook useAuthentcatedFetch which provides a version of fetch that includes the Authentication header when the user is logged in 2. Adds a new useAuthenticatedApiCall which provides a wrapped apiCall using that new authentication header 3. Uses that new authenticatedApiCall for the PUT/POST calls for user-profile Next step will be to integrate with other calls in the app. I also did not attempt to add component integration tests where they do not already exist.
1 parent 6edad4b commit ab93894

File tree

6 files changed

+231
-12
lines changed

6 files changed

+231
-12
lines changed

src/PolicyEngine.jsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import { COUNTRY_BASELINE_POLICIES, COUNTRY_CODES } from "./data/countries";
1616

1717
import { useEffect, useState, lazy, Suspense } from "react";
1818
import {
19-
apiCall,
2019
copySearchParams,
2120
countryApiCall,
2221
updateMetadata,
22+
useAuthenticatedApiCall,
2323
} from "./api/call";
2424
import LoadingCentered from "./layout/LoadingCentered";
2525
import ErrorPage from "./layout/ErrorPage";
@@ -104,6 +104,8 @@ export default function PolicyEngine() {
104104
const [hasShownHouseholdPopup, setHasShownHouseholdPopup] = useState(false);
105105
const [userProfile, setUserProfile] = useState({});
106106

107+
const { authenticatedApiCall } = useAuthenticatedApiCall();
108+
107109
// Update the metadata state when something happens to
108110
// the countryId (e.g. the user changes the country).
109111
useEffect(() => {
@@ -197,7 +199,7 @@ export default function PolicyEngine() {
197199
const USER_PROFILE_PATH = `/${countryId}/user-profile`;
198200
// Determine if user already exists in user profile db
199201
try {
200-
const resGet = await apiCall(
202+
const resGet = await authenticatedApiCall(
201203
USER_PROFILE_PATH + `?auth0_id=${user.sub}`,
202204
);
203205
const resGetJson = await resGet.json();
@@ -211,7 +213,11 @@ export default function PolicyEngine() {
211213
primary_country: countryId,
212214
user_since: Date.now(),
213215
};
214-
const resPost = await apiCall(USER_PROFILE_PATH, body, "POST");
216+
const resPost = await authenticatedApiCall(
217+
USER_PROFILE_PATH,
218+
body,
219+
"POST",
220+
);
215221
const resPostJson = await resPost.json();
216222
if (resPost.status !== 201) {
217223
console.error(
@@ -239,7 +245,7 @@ export default function PolicyEngine() {
239245
if (countryId && isAuthenticated && user?.sub) {
240246
fetchUserProfile().then((userProfile) => setUserProfile(userProfile));
241247
}
242-
}, [countryId, user?.sub, isAuthenticated]);
248+
}, [countryId, user?.sub, isAuthenticated, authenticatedApiCall]);
243249

244250
const loadingPage = (
245251
<>

src/__tests__/api/call.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { useAuthenticatedApiCall } from "../../api/call";
3+
import * as authenticatedFetch from "../../hooks/useAuthenticatedFetch";
4+
5+
jest.mock("../../hooks/useAuthenticatedFetch");
6+
let mock_authenticated_fetch;
7+
const DEFAULT_FETCH_RESULT = {
8+
status: 200,
9+
};
10+
11+
const SOME_REQUEST_BODY = {
12+
content: "BLAH",
13+
};
14+
15+
describe("useAuthenticatedApiCall", () => {
16+
beforeEach(() => {
17+
jest.resetAllMocks();
18+
19+
mock_authenticated_fetch = jest.fn(() =>
20+
Promise.resolve(DEFAULT_FETCH_RESULT),
21+
);
22+
23+
authenticatedFetch.useAuthenticatedFetch.mockReturnValue({
24+
authenticatedFetch: mock_authenticated_fetch,
25+
});
26+
});
27+
28+
test("it should wrap fetch with authenticatedFetch", async () => {
29+
const { result } = renderHook(() => useAuthenticatedApiCall());
30+
31+
const response = await result.current.authenticatedApiCall(
32+
"/test/path",
33+
SOME_REQUEST_BODY,
34+
"POST",
35+
);
36+
37+
expect(response).toEqual(DEFAULT_FETCH_RESULT);
38+
expect(mock_authenticated_fetch.mock.calls[0]).toEqual([
39+
"https://api.policyengine.org/test/path",
40+
{
41+
body: JSON.stringify(SOME_REQUEST_BODY),
42+
headers: {
43+
"Content-Type": "application/json",
44+
},
45+
method: "POST",
46+
},
47+
]);
48+
});
49+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { useAuthenticatedFetch } from "../../hooks/useAuthenticatedFetch";
3+
import auth0 from "@auth0/auth0-react";
4+
5+
jest.mock("@auth0/auth0-react");
6+
const DEFAULT_FETCH_RESULT = "ok";
7+
let mockFetch;
8+
9+
describe("useAuthenticatedFetch", () => {
10+
beforeEach(() => {
11+
jest.resetAllMocks();
12+
auth0.useAuth0.mockReturnValue({
13+
isAuthenticated: false,
14+
getAccessTokenSilently: async () => {
15+
throw new Error("TEST ATTEMPTED TO CALL GET ACCESS TOKENS");
16+
},
17+
});
18+
mockFetch = jest.fn(() => Promise.resolve(DEFAULT_FETCH_RESULT));
19+
global.fetch = mockFetch;
20+
});
21+
22+
function givenTheUserIsLoggedIn(auth_token) {
23+
auth0.useAuth0.mockReturnValue({
24+
isAuthenticated: true,
25+
getAccessTokenSilently: async () => auth_token ?? "TEST_AUTH_TOKEN",
26+
});
27+
}
28+
29+
function givenAuth0TokenCannotBeCreated() {
30+
auth0.useAuth0.mockReturnValue({
31+
isAuthenticated: true,
32+
getAccessTokenSilently: async () => {
33+
throw new Error("TEST ATTEMPTED TO CALL GET ACCESS TOKENS");
34+
},
35+
});
36+
}
37+
test("given the user is logged in then it adds the bearer token", async () => {
38+
givenTheUserIsLoggedIn("TEST_AUTH_TOKEN");
39+
const requestOptions = {
40+
headers: {
41+
whatever: "value",
42+
},
43+
};
44+
45+
const { result } = renderHook(() => useAuthenticatedFetch());
46+
const response = await result.current.authenticatedFetch(
47+
"/test/path",
48+
requestOptions,
49+
);
50+
51+
expect(response).toEqual(DEFAULT_FETCH_RESULT);
52+
expect(mockFetch.mock.calls[0]).toEqual([
53+
"/test/path",
54+
{
55+
...requestOptions,
56+
headers: {
57+
...requestOptions.headers,
58+
Authentication: "Bearer TEST_AUTH_TOKEN",
59+
},
60+
},
61+
]);
62+
});
63+
test("given the user is not logged in then it adds nothing", async () => {
64+
const { result } = renderHook(() => useAuthenticatedFetch());
65+
66+
const response = await result.current.authenticatedFetch("/test/path", {
67+
headers: { whatever: "value" },
68+
});
69+
70+
expect(response).toEqual(DEFAULT_FETCH_RESULT);
71+
expect(mockFetch.mock.calls[0]).toEqual([
72+
"/test/path",
73+
{ headers: { whatever: "value" } },
74+
]);
75+
});
76+
77+
test("given auth0 is not able to get a token then it ignores the error and adds nothing", async () => {
78+
givenAuth0TokenCannotBeCreated();
79+
80+
const { result } = renderHook(() => useAuthenticatedFetch());
81+
82+
const response = await result.current.authenticatedFetch("/test/path", {
83+
headers: { whatever: "value" },
84+
});
85+
86+
expect(response).toEqual(DEFAULT_FETCH_RESULT);
87+
expect(mockFetch.mock.calls[0]).toEqual([
88+
"/test/path",
89+
{ headers: { whatever: "value" } },
90+
]);
91+
});
92+
});

src/api/call.js

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,34 @@
1+
import { useCallback } from "react";
12
import { buildParameterTree } from "./parameters";
23
import { buildVariableTree, getTreeLeavesInOrder } from "./variables";
34
import { wrappedJsonStringify, wrappedResponseJson } from "../data/wrappedJson";
5+
import { useAuthenticatedFetch } from "../hooks/useAuthenticatedFetch";
46

57
const POLICYENGINE_API = "https://api.policyengine.org";
68

9+
/**
10+
* returns an api call function that can be used to make requests
11+
* against the policyengine api endpoint.
12+
*
13+
* @returns {{authenticatedApiCall:(path:string, body:any, method:string)=>Promise}}
14+
*
15+
* @returns
16+
*/
17+
export function useAuthenticatedApiCall() {
18+
const { authenticatedFetch } = useAuthenticatedFetch();
19+
20+
const authenticatedApiCall = useCallback(
21+
(path, body, method) => {
22+
return apiCall(path, body, method, false, authenticatedFetch);
23+
},
24+
[authenticatedFetch],
25+
);
26+
27+
return {
28+
authenticatedApiCall,
29+
};
30+
}
31+
732
/**
833
* Makes an API call to the back end and returns response
934
* @param {String} path API URL, beginning with a slash
@@ -12,10 +37,17 @@ const POLICYENGINE_API = "https://api.policyengine.org";
1237
* or to POST if a body is passed
1338
* @param {boolean} [secondAttempt=false] Whether or not to attempt the request a second
1439
* time if it fails the first time
15-
* @returns {JSON} The API call's response JSON object
40+
* @param {function} [fetchMethod=fetch] Specify a custom fetch method.
41+
* @returns { Promise } The API call's response JSON object
1642
*/
17-
export function apiCall(path, body, method, secondAttempt = false) {
18-
return fetch(POLICYENGINE_API + path, {
43+
export function apiCall(
44+
path,
45+
body,
46+
method,
47+
secondAttempt = false,
48+
fetchMethod = fetch,
49+
) {
50+
return fetchMethod(POLICYENGINE_API + path, {
1951
method: method || (body ? "POST" : "GET"),
2052
headers: {
2153
"Content-Type": "application/json",

src/hooks/useAuthenticatedFetch.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useAuth0 } from "@auth0/auth0-react";
2+
import { useCallback } from "react";
3+
4+
/**
5+
* Get an 'authenticatedFetch' function which, if the user is logged in,
6+
* will automatically attach an access token to any API request.
7+
* @returns {{authenticatedFetch:(path:string, opts:Record)=>Promise}}
8+
*/
9+
export function useAuthenticatedFetch() {
10+
const { isAuthenticated, getAccessTokenSilently } = useAuth0();
11+
12+
const authenticatedFetch = useCallback(
13+
async (path, opts) => {
14+
opts = opts ?? {};
15+
const headers = { ...(opts.headers ?? {}) };
16+
17+
if (isAuthenticated) {
18+
try {
19+
//as per https://auth0.com/docs/quickstart/spa/react/02-calling-an-api
20+
const accessToken = await getAccessTokenSilently();
21+
headers["Authentication"] = `Bearer ${accessToken}`;
22+
} catch (error) {
23+
//IGNORE. If we can't get an access token we just call the API
24+
//without it.
25+
}
26+
}
27+
28+
return await fetch(path, {
29+
...opts,
30+
headers,
31+
});
32+
},
33+
[isAuthenticated, getAccessTokenSilently],
34+
);
35+
return {
36+
authenticatedFetch,
37+
};
38+
}

src/pages/UserProfilePage.jsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import { useDisplayCategory } from "../layout/Responsive";
1818
import { Card, Input, Skeleton, Tooltip } from "antd";
1919
import { useWindowWidth } from "../hooks/useWindow";
20-
import { apiCall } from "../api/call";
20+
import { apiCall, useAuthenticatedApiCall } from "../api/call";
2121
import { useEffect, useState } from "react";
2222
import useCountryId from "../hooks/useCountryId";
2323
import useLocalStorage from "../hooks/useLocalStorage";
@@ -74,6 +74,7 @@ export default function UserProfilePage(props) {
7474
const countryId = useCountryId();
7575
const windowWidth = useWindowWidth();
7676
const dispCat = useDisplayCategory();
77+
const { authenticatedApiCall } = useAuthenticatedApiCall();
7778

7879
const maxCardWidth = 375; // Max card width (relative to screen, so not exact), in pixels
7980
const gridColumns =
@@ -84,7 +85,7 @@ export default function UserProfilePage(props) {
8485
setIsHeaderLoading(true);
8586
if (metadata) {
8687
try {
87-
const data = await apiCall(
88+
const data = await authenticatedApiCall(
8889
`/${countryId}/user-profile?user_id=${accessedUserId}`,
8990
);
9091
const dataJson = await data.json();
@@ -116,7 +117,7 @@ export default function UserProfilePage(props) {
116117
}
117118

118119
fetchProfile();
119-
}, [countryId, isOwnProfile, accessedUserId, metadata]);
120+
}, [countryId, isOwnProfile, accessedUserId, metadata, authenticatedApiCall]);
120121

121122
useEffect(() => {
122123
async function fetchAccessedPolicies() {
@@ -565,6 +566,7 @@ function UsernameDisplayAndEditor(props) {
565566

566567
const [isEditing, setIsEditing] = useState(false);
567568
const [value, setValue] = useState("");
569+
const { authenticatedApiCall } = useAuthenticatedApiCall();
568570

569571
function handleClick() {
570572
setIsEditing((prev) => !prev);
@@ -578,10 +580,10 @@ function UsernameDisplayAndEditor(props) {
578580
};
579581

580582
try {
581-
const res = await apiCall(USER_PROFILE_PATH, body, "PUT");
583+
const res = await authenticatedApiCall(USER_PROFILE_PATH, body, "PUT");
582584
const resJson = await wrappedResponseJson(res);
583585
if (resJson.status === "ok") {
584-
const data = await apiCall(
586+
const data = await authenticatedApiCall(
585587
`/${countryId}/user-profile?user_id=${accessedUserProfile.user_id}`,
586588
);
587589
const dataJson = await data.json();

0 commit comments

Comments
 (0)