An interactive code walk-through on how to authenticate and authorize to use the Azure IoT Central REST APIs
This commit is contained in:
codetunez 2021-01-27 12:58:20 -08:00
Коммит e3eae61273
16 изменённых файлов: 17120 добавлений и 0 удалений

24
.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.eslintcache

17
README.md Normal file
Просмотреть файл

@ -0,0 +1,17 @@
# IOTC-ADD-APP
An interactive code walk-through on how to authenticate and authorize to use the Azure IoT Central REST APIs
## Install
```
npm ci
```
## Run
```
npm start
````
## Usage
```
http://localhost:3000
````

16498
package-lock.json сгенерированный Normal file

Разница между файлами не показана из-за своего большого размера Загрузить разницу

40
package.json Normal file
Просмотреть файл

@ -0,0 +1,40 @@
{
"name": "baseline",
"version": "0.1.0",
"private": true,
"dependencies": {
"@azure/msal-browser": "^2.8.0",
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.6.0",
"axios": "^0.21.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-scripts": "4.0.1",
"web-vitals": "^0.2.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Двоичные данные
public/favicon.ico Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 3.8 KiB

43
public/index.html Normal file
Просмотреть файл

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>IOTC-AAD-APP</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Двоичные данные
public/logo192.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 5.2 KiB

Двоичные данные
public/logo512.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 9.4 KiB

25
public/manifest.json Normal file
Просмотреть файл

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
Просмотреть файл

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

84
src/App.css Normal file
Просмотреть файл

@ -0,0 +1,84 @@
body {
padding: 2rem;
background-color: #cfcfcf;
padding-bottom: 5rem;
}
a {
text-decoration: none;
}
b {
font-weight: 600;
}
h1, h2, h3 {
margin-top: 0
}
button {
cursor: pointer;
padding: 1rem;
font-weight: 600;
border-radius: 4px;
}
.btn-sm {
padding: 0.5rem;
font-weight: 300;
font-size: 0.75rem;
}
.btn-sm:not(:last-child) {
margin-right: 0.5rem;
}
input[type='text'] {
font-size: 1rem;
padding: 0.5rem;
border-radius: 4px;
margin-top: 0.25rem;
width: 20rem;
border: 1px solid black;
}
input:not(:last-child) {
margin-bottom: 1rem;
}
hr {
margin: 2rem 0;
border: 1px solid black;
}
label {
font-weight: 600;
}
.success {
font-weight: 600;
color: green;
}
.error {
font-weight: 600;
color: red;
}
.toggle-display {
display: flex;
flex-direction: column;
}
.toggle-item {
display: flex;
flex-direction: row;
}
.toggle-item>div {
margin-right: .25rem;
}
.toggle-item>div:nth-child(2) {
cursor: pointer;
}

340
src/App.js Normal file
Просмотреть файл

@ -0,0 +1,340 @@
import React from 'react';
import * as axios from 'axios';
import './App.css';
import * as msal from '@azure/msal-browser';
// These are the Scopes
export const Scopes = {
Graph: 'User.Read',
Central: 'https://apps.azureiotcentral.com/user_impersonation',
ARM: 'https://management.azure.com/user_impersonation'
}
// This is a wrapper around the way to call MSAL to authorize against the authority for a given scope
function getAccessTokenForScope(msalInstance, scope, account) {
const tokenRequest = {
scopes: Array.isArray(scope) ? scope : [scope],
forceRefresh: false,
redirectUri: 'http://localhost:3000'
};
if (account) { tokenRequest.account = account };
return new Promise((resolve, reject) => {
msalInstance.acquireTokenSilent(tokenRequest)
.then((res) => {
resolve(res)
})
.catch((err) => {
msalInstance.acquireTokenPopup(tokenRequest)
.then((res) => {
resolve(res)
})
.catch((err) => {
reject(err);
})
})
});
}
// This is a generic function to call an api doing auth with a bearer token
async function callAPI(instance, scope, url, account) {
try {
// if already authorized, this is a call to the cached token
const at = await getAccessTokenForScope(instance, scope, account);
const res = await axios(url, { headers: { Authorization: 'Bearer ' + at.accessToken } })
return res;
} catch (err) {
alert(err);
}
}
// This is a React helper function to make async API calls
const usePromise = ({ promiseFn }) => {
const [loading, setLoading] = React.useState(false);
const [data, setData] = React.useState(null);
const [error, setError] = React.useState(null);
const callPromise = async () => {
setLoading(true);
setData(null);
setError(null);
try {
const res = await promiseFn();
setData(res);
} catch (error) {
setError(error);
}
setLoading(false);
};
return [loading, data, error, callPromise];
};
// This is the main React component
function App() {
// ADD Application configuration
const [tenantId, setTenantId] = React.useState('');
const [clientId, setClientId] = React.useState('');
// MSAL token objects
const [instance, setInstance] = React.useState();
const [account, setAccount] = React.useState();
const [central, setCentral] = React.useState();
const [arm, setArm] = React.useState();
// UX
const [toggles, setToggler] = React.useState({});
const [injectAccount, setInjectAccount] = React.useState(false);
const [subscriptionId, setSubscriptionId] = React.useState('');
// eslint-disable-next-line
const [progressFetchMe, me, errorFetchMe, fetchMe] = usePromise({
promiseFn: () => callAPI(instance.msal, Scopes.Graph, `https://graph.microsoft.com/v1.0/me`, injectAccount ? account.graphToken.account : null)
});
// eslint-disable-next-line
const [progressFetchTemplates, templates, errorFetchTemplate, fetchTemplates] = usePromise({
promiseFn: () => callAPI(instance.msal, Scopes.Central, `https://${appHost}/api/preview/deviceTemplates`, injectAccount ? account.graphToken.account : null)
});
// eslint-disable-next-line
const [progressFetchDevices, devices, errorFetchDevices, fetchDevices] = usePromise({
promiseFn: () => callAPI(instance.msal, Scopes.Central, `https://${appHost}/api/preview/devices`, injectAccount ? account.graphToken.account : null)
});
// eslint-disable-next-line
const [progressFetchSubscription, subscriptions, errorFetchSubscription, fetchSubscription] = usePromise({
promiseFn: () => callAPI(instance.msal, Scopes.ARM, `https://management.azure.com/subscriptions?api-version=2020-01-01`, injectAccount ? account.graphToken.account : null)
});
// eslint-disable-next-line
const [progressFetchTenants, tenants, errorFetchTenants, fetchTenants] = usePromise({
promiseFn: () => callAPI(instance.msal, Scopes.ARM, `https://management.azure.com/tenants?api-version=2020-01-01`, injectAccount ? account.graphToken.account : null)
});
// eslint-disable-next-line
const [progressFetchApps, apps, errorFetchApps, fetchApps] = usePromise({
promiseFn: () => callAPI(instance.msal, Scopes.ARM, `https://management.azure.com/subscriptions/${subscriptionId}/providers/Microsoft.IoTCentral/IoTApps?api-version=2018-09-01`, injectAccount ? account.graphToken.account : null)
});
// Step 1
const updateClient = () => {
const instance = new msal.PublicClientApplication({
auth: {
clientId: clientId,
authority: 'https://login.microsoftonline.com/' + tenantId
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: false,
}
});
setInstance({ msal: instance, cachedAccounts: instance.getAllAccounts() })
}
// Step 2
const getGraphToken = () => {
getAccessTokenForScope(instance.msal, Scopes.Graph, injectAccount ? account.graphToken.account : null)
.then((res) => { setAccount({ graphToken: res }); })
.catch((err) => { alert(JSON.stringify(err, null, 2)); })
}
// Step 3a
const [appHost, setAppHost] = React.useState('');
// Step 3b
const getCentralToken = () => {
getAccessTokenForScope(instance.msal, Scopes.Central, injectAccount ? account.graphToken.account : null)
.then((res) => { setCentral({ centralAccessToken: res }); })
.catch((err) => { alert(JSON.stringify(err, null, 2)); })
}
// Step 4
const getArmToken = () => {
getAccessTokenForScope(instance.msal, Scopes.ARM, injectAccount ? account.graphToken.account : null)
.then((res) => { setArm({ armAccessToken: res }); })
.catch((err) => { alert(JSON.stringify(err, null, 2)); })
}
// Step 1 - Sub task
const clearStorage = () => {
localStorage.clear();
sessionStorage.clear();
alert('Session and local storage cleared. Please shutdown and re-launch the browser');
}
const signOut = (instance) => {
instance.logout();
}
function showStatus(condition, success, error) {
return <div>{condition ? <span className='success'>{success}</span> : <span className='error'>{error}</span>}</div>
}
function showJSON(toggles, json, handler, ns) {
const dom = [];
for (const name in json) {
const key = ns + name;
const expand = toggles[key] ? toggles[key] : false;
dom.push(<div className='toggle-item'><div>{name}</div><div onClick={() => { handler(key) }}>{expand ? <span>&#9660;</span> : <span>&#9650;</span>}</div>{expand ? <pre>{JSON.stringify(json[name], null, 2)}</pre> : null}</div>)
}
return <div className='toggle-display'>{dom}</div>;
}
const toggle = (key) => {
const toggle = Object.assign({}, toggles);
toggle[key] = toggle[key] ? !toggle[key] : true;
setToggler(toggle)
}
return (<div className='App'>
<h1>Authentication and Authorization walk-through for using Azure IoT Central REST APIs</h1>
<p>This codebase demonstrates flows to authenticate and authorize a user to call Azure IoT Central REST APIs for data and control plane operations. It is best suited to single directory authentication models and should not be used to build multi-tenancy applications.</p>
<h2>Prerequisites</h2>
<ul>
<li>A Microsoft account</li>
<li>An Azure Subscription</li>
<li>An Azure Active Directory with you added as admin</li>
<li>The ability to create and admin an Azure Active Directory application using the above Subscription/Directory</li>
</ul>
<h4>AAD Application scopes</h4>
<p>To learn more about setting up an AAD Application please visit <a href='https://github.com/iot-for-all/iotc-aad-setup'>this</a> repo. Ensure the following scopes are configured in the application</p>
<ul>
<li>Graph -'User.Read'</li>
<li>Central - 'https://apps.azureiotcentral.com/user_impersonation'</li>
<li>ARM - 'https://management.azure.com/user_impersonation'</li>
</ul>
<p>This sample code should be used for reference and pattern adoption. <b>It is not recommended for hosting/production as it exposes bearer tokens to the screen.</b> If your SPA framework is React, it is recommended to use the hooks implementation within the library. Please set the browser to always allow for pop ups from http://locahost:3000. This will ease debugging when setting up the AAD application.</p>
{/* Step 1 */}
<hr />
<h2>1. Set up the MSAL instance</h2>
<p>The MSAL framework makes doing authentication against AAD pain free by taking care of caching and refreshing all obtained tokens. This browser version includes handling all types of user interactions to capture user credentials. Visit the <a href='https://github.com/AzureAD/microsoft-authentication-library-for-js'>MSAL.js</a> github repo for details on this library. <b>This will be the only dependency required for your web application.</b></p>
<div>
<label>AAD Application Client ID</label><br />
<input type='text' value={clientId} onChange={(e) => setClientId(e.target.value)} placeholder='e.g. f095895b-0529-4839-af23-58eb3aa12a54'></input>
<br />
<label>AAD Directory Tenant ID</label><br />
<input type='text' value={tenantId} onChange={(e) => setTenantId(e.target.value)} placeholder='e.g. 0786922d-1889-4538-9695-dd37032b2a39'></input>
</div>
<br />
<button onClick={() => { updateClient(); }}>Create MSAL instance</button>
<br /><br />
<label>Result(s)</label><br />
{showStatus(instance, showJSON(toggles, instance, toggle, 'instance'), 'MSAL.js instance has not been created')}
<br />
<div>
<button className='btn-sm' onClick={() => { clearStorage(); }}>Clear local/session storage data</button>
<button disabled={!instance} className='btn-sm' onClick={() => { signOut(instance.msal); }}>Sign out if already authenticated</button>
</div>
{/* Step 2 */}
<hr />
<h2>2. Optional - Authorize against MS Graph to gain access to user's profile data. Additionally get an account</h2>
<p>Starting authentication with MS Graph authorization will allow you to obtain an account object that can be injected into subsequent authorization calls (you can also use one of the cached accounts) Without this, each authorization call will require MSAL to use the account associate to the scope request increasing the time through the authentication cycle. The added benefit of making this call first is to get user profile information such as name and email</p>
<button disabled={!instance} onClick={() => { getGraphToken(); }}>Auth to get access token for MS Graph APIs</button>
<br /><br />
<input disabled={!account} type="checkbox" value={injectAccount} onChange={(e) => setInjectAccount(e.target.checked)} /> Inject the account returned from this call into all subsequent calls for request tokens or API calls
<br />
<label>Result</label><br />
{showStatus(account, showJSON(toggles, account, toggle, 'account'), 'Need to obtain MS Graph access token')}
{showStatus(instance, '', 'Needs Step 1')}
<br />
<label>MS Graph APIs</label><br />
<span>To see the full list of MS Graph REST APIs visit <a href='https://docs.microsoft.com/en-us/azure/active-directory/develop/microsoft-graph-intro'>this</a> documentation site.</span>
<br /><br />
<div>
<button disabled={!account} className='btn-sm' onClick={() => { fetchMe(); }}>Fetch Me</button>
</div>
{progressFetchMe && !me ? <><br />{'Fetching Me'}<br /></> : me ? <><br />Me<br />{showStatus(me, showJSON(toggles, me, toggle, 'me'), '')}</> : null}
{/* Step 3a */}
<hr />
<h2>3a. Authorize against Central to gain access to data plane REST APIs </h2>
<p><b>Skip if only control plane access is required</b></p>
<p>This authorization is required to fetch data from your IoT Central application. If an account has not been provided with the scope request, the user may have to login or be redirected through a silent login phase.</p>
<button disabled={!instance} onClick={() => { getCentralToken(); }}>Get access token for IoT Central REST APIs</button>
<br /><br />
<label>Result</label><br />
{showStatus(central, showJSON(toggles, central, toggle, 'central'), 'Need to obtain IoT Central REST API access token')}
{showStatus(instance, '', 'Need Step 1')}
{/* Step 3b */}
<hr />
<h2>3b. Define the IoT Central application host name to call IoT Central data plane REST APIs</h2>
<p><b>Skip if only control plane access is required</b></p>
<p>This is required for the domain the REST calls need to use to do data plane operations. It is the given by the application name + the iotcenntral domain name e.g. myapp.azureiotcentral.com. If an account has not been provided with the scope request, the user may have to login or be redirected. The application name is also available through the applications call using the ARM APIs (see later) and is the subdomain property.</p>
<label>Application host name</label><br />
<div>
<input type='text' value={appHost} onChange={(e) => setAppHost(e.target.value)} placeholder='e.g. <appname>.azureiotcentral.com'></input>
</div>
<br />
<label>Result</label><br />
{showStatus(appHost !== '' && appHost.indexOf('.com') >= 0 && appHost.indexOf('azureiotcentral') >= 0, 'Application host defined', 'No application host defined')}
{showStatus(central, '', 'Need Step 3a')}
<br />
<label>APIs</label><br />
<span>To see the full list of IoT Central REST APIs visit <a href='https://docs.microsoft.com/en-us/rest/api/iotcentral/'>this</a> documentation site.</span>
<br /><br />
<div>
<button disabled={!instance || !central || appHost === ''} className='btn-sm' onClick={() => { fetchTemplates(); }}>Fetch applications templates</button>
<button disabled={!instance || !central || appHost === ''} className='btn-sm' onClick={() => { fetchDevices(); }}>Fetch all devices from application</button>
</div>
{progressFetchTemplates && !templates ? <><br />{'Fetching Templates'}<br /></> : templates ? <><br />Templates<br />{showStatus(templates, showJSON(toggles, templates, toggle, 'templates'), '')}</> : null}
{progressFetchDevices && !devices ? <><br />{'Fetching Devices'}<br /></> : devices ? <><br />Devices<br />{showStatus(devices, showJSON(toggles, devices, toggle, 'devices'), '')}</> : null}
{/* Step 3c */}
<hr />
<h2>3c. Optional - Check Single Sign On with Azure IoT Central and Microsoft Outlook</h2>
<p><b>Skip if only control plane access is required</b></p>
<p>Because authentication has happened, any visit to another site that is using the same Scope request will mean the user will not need to sign-on again (or sign-in without needing to provide a password). Setting up MSAL.js to use localStorage enables this.</p>
<p>Visit the following sites for SSO scenarios</p>
<label>IoT Central application URL</label><br />
{showStatus(central, '', 'Need Step 3a')}
<a href={'https://' + appHost} rel='noreferrer' target='_blank'>{'https://' + appHost}</a>
<br /><br />
<label>Microsoft Outlook URL</label><br />
{showStatus(account, '', 'Need Step 2')}
<a href='https://outlook.live.com' rel='noreferrer' target='_blank'>https://outlook.live.com</a>
{/* Step 4 */}
<hr />
<h2>4. Authorize against ARM to gain access to IoT Central control plane REST APIs </h2>
<p><b>Skip if only data plane access is required</b></p>
<p>This authorization is required to do control plane operations for your IoT Central application. If an account has not been provided with the scope request, the user may have to login or be redirected through a silent login phase</p>
<button disabled={!instance} onClick={() => { getArmToken(); }}>Auth to get access token for ARM REST APIs</button>
<br /><br />
<label>Result</label><br />
{showStatus(arm, showJSON(toggles, arm, toggle, 'arm'), 'Need to obtain ARM REST API access token')}
{showStatus(instance, '', 'Need Step 1')}
<br />
<label>ARM APIs</label><br />
<span>To see the full list of ARM REST APIs visit <a href='https://docs.microsoft.com/en-us/rest/api/resources/'>this</a> documentation site.</span>
<br /><br />
<div>
<button disabled={!arm} className='btn-sm' onClick={() => { fetchSubscription(); }}>Fetch Subscriptions</button>
<button disabled={!arm} className='btn-sm' onClick={() => { fetchTenants(); }}>Fetch Tenants</button>
</div>
{progressFetchSubscription && !subscriptions ? <><br />{'Fetching Subscriptions'}<br /></> : subscriptions ? <><br />Subscriptions<br />{showStatus(subscriptions, showJSON(toggles, subscriptions, toggle, 'subs'), '')}</> : null}
{progressFetchTenants && !tenants ? <><br />{'Fetching Tenants'}<br /></> : tenants ? <><br />Tenants<br />{showStatus(tenants, showJSON(toggles, tenants, toggle, 'tenants'), '')}</> : null}
<br /><br />
<label>IoT Central ARM APIs</label><br />
<span>To see the full list of IoT Central ARM REST APIs visit <a href='https://docs.microsoft.com/en-us/azure/templates/microsoft.iotcentral/iotapps'>this</a> documentation site.</span>
<p>For every IoT Central ARM API call, a Subscription ID is required.</p>
<div>
<label>Subscription ID</label><br />
<input disabled={!arm} type='text' value={subscriptionId} onChange={(e) => setSubscriptionId(e.target.value)} placeholder='e.g. aed4abeb-3420-45ba-8ebf-5fd3b2ee891b'></input>
</div>
<br />
<div>
<button disabled={!arm || subscriptionId === ''} className='btn-sm' onClick={() => { fetchApps(); }}>Fetch all Apps for this Subscription</button>
</div>
{progressFetchApps && !devices ? <><br />{'Fetching Apps'}<br /></> : apps ? <><br />Apps<br />{showStatus(apps, showJSON(toggles, apps, toggle, 'apps'), '')}</> : null}
</div>);
}
export default App;

10
src/index.css Normal file
Просмотреть файл

@ -0,0 +1,10 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}

18
src/index.js Normal file
Просмотреть файл

@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

13
src/reportWebVitals.js Normal file
Просмотреть файл

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

5
src/setupTests.js Normal file
Просмотреть файл

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';