Skip to content

Commit

Permalink
Add bearer token just to user-profile
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Michael Smit authored and mikesmit committed Dec 23, 2024
1 parent 6edad4b commit ab93894
Show file tree
Hide file tree
Showing 6 changed files with 231 additions and 12 deletions.
14 changes: 10 additions & 4 deletions src/PolicyEngine.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import { COUNTRY_BASELINE_POLICIES, COUNTRY_CODES } from "./data/countries";

import { useEffect, useState, lazy, Suspense } from "react";
import {
apiCall,
copySearchParams,
countryApiCall,
updateMetadata,
useAuthenticatedApiCall,
} from "./api/call";
import LoadingCentered from "./layout/LoadingCentered";
import ErrorPage from "./layout/ErrorPage";
Expand Down Expand Up @@ -104,6 +104,8 @@ export default function PolicyEngine() {
const [hasShownHouseholdPopup, setHasShownHouseholdPopup] = useState(false);
const [userProfile, setUserProfile] = useState({});

const { authenticatedApiCall } = useAuthenticatedApiCall();

// Update the metadata state when something happens to
// the countryId (e.g. the user changes the country).
useEffect(() => {
Expand Down Expand Up @@ -197,7 +199,7 @@ export default function PolicyEngine() {
const USER_PROFILE_PATH = `/${countryId}/user-profile`;
// Determine if user already exists in user profile db
try {
const resGet = await apiCall(
const resGet = await authenticatedApiCall(
USER_PROFILE_PATH + `?auth0_id=${user.sub}`,
);
const resGetJson = await resGet.json();
Expand All @@ -211,7 +213,11 @@ export default function PolicyEngine() {
primary_country: countryId,
user_since: Date.now(),
};
const resPost = await apiCall(USER_PROFILE_PATH, body, "POST");
const resPost = await authenticatedApiCall(
USER_PROFILE_PATH,
body,
"POST",
);
const resPostJson = await resPost.json();
if (resPost.status !== 201) {
console.error(
Expand Down Expand Up @@ -239,7 +245,7 @@ export default function PolicyEngine() {
if (countryId && isAuthenticated && user?.sub) {
fetchUserProfile().then((userProfile) => setUserProfile(userProfile));
}
}, [countryId, user?.sub, isAuthenticated]);
}, [countryId, user?.sub, isAuthenticated, authenticatedApiCall]);

const loadingPage = (
<>
Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/api/call.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { renderHook } from "@testing-library/react";
import { useAuthenticatedApiCall } from "../../api/call";
import * as authenticatedFetch from "../../hooks/useAuthenticatedFetch";

jest.mock("../../hooks/useAuthenticatedFetch");
let mock_authenticated_fetch;
const DEFAULT_FETCH_RESULT = {
status: 200,
};

const SOME_REQUEST_BODY = {
content: "BLAH",
};

describe("useAuthenticatedApiCall", () => {
beforeEach(() => {
jest.resetAllMocks();

mock_authenticated_fetch = jest.fn(() =>
Promise.resolve(DEFAULT_FETCH_RESULT),
);

authenticatedFetch.useAuthenticatedFetch.mockReturnValue({
authenticatedFetch: mock_authenticated_fetch,
});
});

test("it should wrap fetch with authenticatedFetch", async () => {
const { result } = renderHook(() => useAuthenticatedApiCall());

const response = await result.current.authenticatedApiCall(
"/test/path",
SOME_REQUEST_BODY,
"POST",
);

expect(response).toEqual(DEFAULT_FETCH_RESULT);
expect(mock_authenticated_fetch.mock.calls[0]).toEqual([
"https://api.policyengine.org/test/path",
{
body: JSON.stringify(SOME_REQUEST_BODY),
headers: {
"Content-Type": "application/json",
},
method: "POST",
},
]);
});
});
92 changes: 92 additions & 0 deletions src/__tests__/hooks/useAuthenticatedFetch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { renderHook } from "@testing-library/react";
import { useAuthenticatedFetch } from "../../hooks/useAuthenticatedFetch";
import auth0 from "@auth0/auth0-react";

jest.mock("@auth0/auth0-react");
const DEFAULT_FETCH_RESULT = "ok";
let mockFetch;

describe("useAuthenticatedFetch", () => {
beforeEach(() => {
jest.resetAllMocks();
auth0.useAuth0.mockReturnValue({
isAuthenticated: false,
getAccessTokenSilently: async () => {
throw new Error("TEST ATTEMPTED TO CALL GET ACCESS TOKENS");
},
});
mockFetch = jest.fn(() => Promise.resolve(DEFAULT_FETCH_RESULT));
global.fetch = mockFetch;
});

function givenTheUserIsLoggedIn(auth_token) {
auth0.useAuth0.mockReturnValue({
isAuthenticated: true,
getAccessTokenSilently: async () => auth_token ?? "TEST_AUTH_TOKEN",
});
}

function givenAuth0TokenCannotBeCreated() {
auth0.useAuth0.mockReturnValue({
isAuthenticated: true,
getAccessTokenSilently: async () => {
throw new Error("TEST ATTEMPTED TO CALL GET ACCESS TOKENS");
},
});
}
test("given the user is logged in then it adds the bearer token", async () => {
givenTheUserIsLoggedIn("TEST_AUTH_TOKEN");
const requestOptions = {
headers: {
whatever: "value",
},
};

const { result } = renderHook(() => useAuthenticatedFetch());
const response = await result.current.authenticatedFetch(
"/test/path",
requestOptions,
);

expect(response).toEqual(DEFAULT_FETCH_RESULT);
expect(mockFetch.mock.calls[0]).toEqual([
"/test/path",
{
...requestOptions,
headers: {
...requestOptions.headers,
Authentication: "Bearer TEST_AUTH_TOKEN",
},
},
]);
});
test("given the user is not logged in then it adds nothing", async () => {
const { result } = renderHook(() => useAuthenticatedFetch());

const response = await result.current.authenticatedFetch("/test/path", {
headers: { whatever: "value" },
});

expect(response).toEqual(DEFAULT_FETCH_RESULT);
expect(mockFetch.mock.calls[0]).toEqual([
"/test/path",
{ headers: { whatever: "value" } },
]);
});

test("given auth0 is not able to get a token then it ignores the error and adds nothing", async () => {
givenAuth0TokenCannotBeCreated();

const { result } = renderHook(() => useAuthenticatedFetch());

const response = await result.current.authenticatedFetch("/test/path", {
headers: { whatever: "value" },
});

expect(response).toEqual(DEFAULT_FETCH_RESULT);
expect(mockFetch.mock.calls[0]).toEqual([
"/test/path",
{ headers: { whatever: "value" } },
]);
});
});
38 changes: 35 additions & 3 deletions src/api/call.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,34 @@
import { useCallback } from "react";
import { buildParameterTree } from "./parameters";
import { buildVariableTree, getTreeLeavesInOrder } from "./variables";
import { wrappedJsonStringify, wrappedResponseJson } from "../data/wrappedJson";
import { useAuthenticatedFetch } from "../hooks/useAuthenticatedFetch";

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

/**
* returns an api call function that can be used to make requests
* against the policyengine api endpoint.
*
* @returns {{authenticatedApiCall:(path:string, body:any, method:string)=>Promise}}
*
* @returns
*/
export function useAuthenticatedApiCall() {
const { authenticatedFetch } = useAuthenticatedFetch();

const authenticatedApiCall = useCallback(
(path, body, method) => {
return apiCall(path, body, method, false, authenticatedFetch);
},
[authenticatedFetch],
);

return {
authenticatedApiCall,
};
}

/**
* Makes an API call to the back end and returns response
* @param {String} path API URL, beginning with a slash
Expand All @@ -12,10 +37,17 @@ const POLICYENGINE_API = "https://api.policyengine.org";
* or to POST if a body is passed
* @param {boolean} [secondAttempt=false] Whether or not to attempt the request a second
* time if it fails the first time
* @returns {JSON} The API call's response JSON object
* @param {function} [fetchMethod=fetch] Specify a custom fetch method.
* @returns { Promise } The API call's response JSON object
*/
export function apiCall(path, body, method, secondAttempt = false) {
return fetch(POLICYENGINE_API + path, {
export function apiCall(
path,
body,
method,
secondAttempt = false,
fetchMethod = fetch,
) {
return fetchMethod(POLICYENGINE_API + path, {
method: method || (body ? "POST" : "GET"),
headers: {
"Content-Type": "application/json",
Expand Down
38 changes: 38 additions & 0 deletions src/hooks/useAuthenticatedFetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useAuth0 } from "@auth0/auth0-react";
import { useCallback } from "react";

/**
* Get an 'authenticatedFetch' function which, if the user is logged in,
* will automatically attach an access token to any API request.
* @returns {{authenticatedFetch:(path:string, opts:Record)=>Promise}}
*/
export function useAuthenticatedFetch() {
const { isAuthenticated, getAccessTokenSilently } = useAuth0();

const authenticatedFetch = useCallback(
async (path, opts) => {
opts = opts ?? {};
const headers = { ...(opts.headers ?? {}) };

if (isAuthenticated) {
try {
//as per https://auth0.com/docs/quickstart/spa/react/02-calling-an-api
const accessToken = await getAccessTokenSilently();
headers["Authentication"] = `Bearer ${accessToken}`;
} catch (error) {
//IGNORE. If we can't get an access token we just call the API
//without it.
}
}

return await fetch(path, {
...opts,
headers,
});
},
[isAuthenticated, getAccessTokenSilently],
);
return {
authenticatedFetch,
};
}
12 changes: 7 additions & 5 deletions src/pages/UserProfilePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import { useDisplayCategory } from "../layout/Responsive";
import { Card, Input, Skeleton, Tooltip } from "antd";
import { useWindowWidth } from "../hooks/useWindow";
import { apiCall } from "../api/call";
import { apiCall, useAuthenticatedApiCall } from "../api/call";
import { useEffect, useState } from "react";
import useCountryId from "../hooks/useCountryId";
import useLocalStorage from "../hooks/useLocalStorage";
Expand Down Expand Up @@ -74,6 +74,7 @@ export default function UserProfilePage(props) {
const countryId = useCountryId();
const windowWidth = useWindowWidth();
const dispCat = useDisplayCategory();
const { authenticatedApiCall } = useAuthenticatedApiCall();

const maxCardWidth = 375; // Max card width (relative to screen, so not exact), in pixels
const gridColumns =
Expand All @@ -84,7 +85,7 @@ export default function UserProfilePage(props) {
setIsHeaderLoading(true);
if (metadata) {
try {
const data = await apiCall(
const data = await authenticatedApiCall(
`/${countryId}/user-profile?user_id=${accessedUserId}`,
);
const dataJson = await data.json();
Expand Down Expand Up @@ -116,7 +117,7 @@ export default function UserProfilePage(props) {
}

fetchProfile();
}, [countryId, isOwnProfile, accessedUserId, metadata]);
}, [countryId, isOwnProfile, accessedUserId, metadata, authenticatedApiCall]);

useEffect(() => {
async function fetchAccessedPolicies() {
Expand Down Expand Up @@ -565,6 +566,7 @@ function UsernameDisplayAndEditor(props) {

const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState("");
const { authenticatedApiCall } = useAuthenticatedApiCall();

function handleClick() {
setIsEditing((prev) => !prev);
Expand All @@ -578,10 +580,10 @@ function UsernameDisplayAndEditor(props) {
};

try {
const res = await apiCall(USER_PROFILE_PATH, body, "PUT");
const res = await authenticatedApiCall(USER_PROFILE_PATH, body, "PUT");
const resJson = await wrappedResponseJson(res);
if (resJson.status === "ok") {
const data = await apiCall(
const data = await authenticatedApiCall(
`/${countryId}/user-profile?user_id=${accessedUserProfile.user_id}`,
);
const dataJson = await data.json();
Expand Down

0 comments on commit ab93894

Please sign in to comment.