Skip to content
This repository has been archived by the owner on Apr 13, 2023. It is now read-only.

test: add multi-tenancy integ tests #387

Merged
merged 2 commits into from
Jul 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions integration-tests/bulkExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { getFhirClient } from './utils';
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
jest.setTimeout(FIVE_MINUTES_IN_MS);

const sleep = async (milliseconds: number) => {
return new Promise(resolve => setTimeout(resolve, milliseconds));
};

describe('Bulk Export', () => {
let bulkExportTestHelper: BulkExportTestHelper;

Expand All @@ -21,6 +25,8 @@ describe('Bulk Export', () => {
// BUILD
const oldCreatedResourceBundleResponse = await bulkExportTestHelper.sendCreateResourcesRequest();
const resTypToResNotExpectedInExport = bulkExportTestHelper.getResources(oldCreatedResourceBundleResponse);
// sleep 30 seconds to make tests more resilient to clock skew when running locally.
await sleep(30_000);
const currentTime = new Date();
const newCreatedResourceBundleResponse = await bulkExportTestHelper.sendCreateResourcesRequest();
const resTypToResExpectedInExport = bulkExportTestHelper.getResources(newCreatedResourceBundleResponse);
Expand Down
200 changes: 200 additions & 0 deletions integration-tests/multitenancy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0 *
*/

import { AxiosInstance } from 'axios';
import {
expectResourceToNotBePartOfSearchResults,
getFhirClient,
randomPatient,
waitForResourceToBeSearchable,
} from './utils';
import BulkExportTestHelper from './bulkExportTestHelper';

jest.setTimeout(300_000);

test('empty test placeholder', () => {
rsmayda marked this conversation as resolved.
Show resolved Hide resolved
// empty test to avoid the "Your test suite must contain at least one test." error
});

if (process.env.MULTI_TENANCY_ENABLED === 'true') {
describe('tenant data isolation', () => {
rsmayda marked this conversation as resolved.
Show resolved Hide resolved
let client: AxiosInstance;
let clientForAnotherTenant: AxiosInstance;
beforeAll(async () => {
client = await getFhirClient({ tenant: 'tenant1' });
clientForAnotherTenant = await getFhirClient({ tenant: 'tenant2' });
rsmayda marked this conversation as resolved.
Show resolved Hide resolved
});

test('tenant cannot READ resources from another tenant', async () => {
const testPatient: ReturnType<typeof randomPatient> = (await client.post('Patient', randomPatient())).data;

await expect(clientForAnotherTenant.get(`Patient/${testPatient.id}`)).rejects.toMatchObject({
response: { status: 404 },
});
});

test('tenant cannot UPDATE resources from another tenant', async () => {
const testPatient: ReturnType<typeof randomPatient> = (await client.post('Patient', randomPatient())).data;

await expect(clientForAnotherTenant.put(`Patient/${testPatient.id}`, testPatient)).rejects.toMatchObject({
response: { status: 404 },
});
});

test('tenant cannot DELETE resources from another tenant', async () => {
const testPatient: ReturnType<typeof randomPatient> = (await client.post('Patient', randomPatient())).data;

await expect(clientForAnotherTenant.delete(`Patient/${testPatient.id}`)).rejects.toMatchObject({
response: { status: 404 },
});
});

test('tenant cannot SEARCH resources from another tenant', async () => {
const testPatient: ReturnType<typeof randomPatient> = (await client.post('Patient', randomPatient())).data;

await waitForResourceToBeSearchable(client, testPatient);

await expectResourceToNotBePartOfSearchResults(
clientForAnotherTenant,
{ url: 'Patient', params: { _id: testPatient.id } },
testPatient,
);
});

test('tenant cannot SEARCH _include or _revinclude resources from another tenant', async () => {
const testOrganization = {
resourceType: 'Organization',
name: 'Some Organization',
};

const testOrganizationResource = (await client.post('Organization', testOrganization)).data;

const testPatientWithRelativeReferenceToOrg: ReturnType<typeof randomPatient> = (
await clientForAnotherTenant.post('Patient', {
...randomPatient(),
managingOrganization: {
reference: `Organization/${testOrganizationResource.id}`,
},
})
).data;

const testPatientWithAbsoluteReferenceToOrg: ReturnType<typeof randomPatient> = (
await clientForAnotherTenant.post('Patient', {
...randomPatient(),
managingOrganization: {
reference: `${process.env.API_URL}/tenant/tenant1/Organization/${testOrganizationResource.id}`,
},
})
).data;

await waitForResourceToBeSearchable(clientForAnotherTenant, testPatientWithAbsoluteReferenceToOrg);

await expectResourceToNotBePartOfSearchResults(
clientForAnotherTenant,
{ url: 'Patient', params: { _id: testPatientWithRelativeReferenceToOrg.id, _include: '*' } },
carvantes marked this conversation as resolved.
Show resolved Hide resolved
testOrganizationResource,
);

await expectResourceToNotBePartOfSearchResults(
clientForAnotherTenant,
{ url: 'Patient', params: { _id: testPatientWithAbsoluteReferenceToOrg.id, _include: '*' } },
testOrganizationResource,
);

await expectResourceToNotBePartOfSearchResults(
client,
{ url: 'Organization', params: { _id: testOrganizationResource.id, _revinclude: '*' } },
testPatientWithAbsoluteReferenceToOrg,
);

await expectResourceToNotBePartOfSearchResults(
client,
{ url: 'Organization', params: { _id: testOrganizationResource.id, _revinclude: '*' } },
testPatientWithRelativeReferenceToOrg,
);
});

test('tenant cannot EXPORT resources from another tenant', async () => {
const testPatient: ReturnType<typeof randomPatient> = (await client.post('Patient', randomPatient())).data;
const bulkExportTestHelper = new BulkExportTestHelper(clientForAnotherTenant);

const testPatientFromAnotherTenant: ReturnType<typeof randomPatient> = (
await clientForAnotherTenant.post('Patient', randomPatient())
).data;

const statusPollUrl = await bulkExportTestHelper.startExportJob({
since: new Date(Date.now() - 600_000),
});
const responseBody = await bulkExportTestHelper.getExportStatus(statusPollUrl);

const expectedResources = { Patient: testPatientFromAnotherTenant };
const notExpectedResources = { Patient: testPatient };

return bulkExportTestHelper.checkResourceInExportedFiles(
responseBody.output,
expectedResources,
notExpectedResources,
);
});
});

describe('routing', () => {
let client: AxiosInstance;
beforeAll(async () => {
client = await getFhirClient({ tenant: 'tenant1' });
});
test('requests without /tenant/<tenantId> in path should fail', async () => {
await expect(client.get(`${process.env.API_URL}/Patient`)).rejects.toMatchObject({
response: { status: 404 },
});

await expect(client.get(`${process.env.API_URL}/Patient/123`)).rejects.toMatchObject({
response: { status: 404 },
});

await expect(client.post(`${process.env.API_URL}/Patient/123`)).rejects.toMatchObject({
response: { status: 404 },
});

await expect(client.put(`${process.env.API_URL}/Patient/123`)).rejects.toMatchObject({
response: { status: 404 },
});

await expect(client.delete(`${process.env.API_URL}/Patient/123`)).rejects.toMatchObject({
response: { status: 404 },
});
});

test('requests with tenantId in path different from the tenantId in access token should fail', async () => {
await expect(client.get(`${process.env.API_URL}/tenant/anotherTenantId/Patient`)).rejects.toMatchObject({
response: { status: 401 },
});

await expect(client.get(`${process.env.API_URL}/tenant/anotherTenantId/Patient/123`)).rejects.toMatchObject(
{
response: { status: 401 },
},
);

await expect(
client.post(`${process.env.API_URL}/tenant/anotherTenantId/Patient/123`),
).rejects.toMatchObject({
response: { status: 401 },
});

await expect(client.put(`${process.env.API_URL}/tenant/anotherTenantId/Patient/123`)).rejects.toMatchObject(
{
response: { status: 401 },
},
);

await expect(
client.delete(`${process.env.API_URL}/tenant/anotherTenantId/Patient/123`),
).rejects.toMatchObject({
response: { status: 401 },
});
});
});
}
8 changes: 4 additions & 4 deletions integration-tests/rbac-permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getFhirClient, randomPatient } from './utils';
jest.setTimeout(60 * 1000);

test('practitioner role can create new patient', async () => {
const client = await getFhirClient('practitioner');
const client = await getFhirClient({ role: 'practitioner' });
const patientRecord: any = randomPatient();
delete patientRecord.id;
await expect(client.post('Patient', patientRecord)).resolves.toMatchObject({
Expand All @@ -14,16 +14,16 @@ test('practitioner role can create new patient', async () => {

describe('Negative tests', () => {
test('invalid token', async () => {
const client = await getFhirClient('practitioner', 'Invalid token');
const client = await getFhirClient({ role: 'practitioner', providedAccessToken: 'Invalid token' });
await expect(client.post('Patient', randomPatient())).rejects.toMatchObject({
response: { status: 401 },
});
});

test('auditor role cannot create new patient record', async () => {
const client = await getFhirClient('auditor');
const client = await getFhirClient({ role: 'auditor' });
await expect(client.post('Patient', randomPatient())).rejects.toMatchObject({
response: { status: 403 },
response: { status: 401 },
rsmayda marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
112 changes: 88 additions & 24 deletions integration-tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,76 @@ import * as AWS from 'aws-sdk';
import axios, { AxiosInstance } from 'axios';
import { Chance } from 'chance';
import qs from 'qs';
import { decode } from 'jsonwebtoken';
import waitForExpect from 'wait-for-expect';

export const getFhirClient = async (
role: 'auditor' | 'practitioner' = 'practitioner',
providedAccessToken?: string,
const DEFAULT_TENANT_ID = 'tenant1';

const getAuthParameters: (role: string, tenantId: string) => { PASSWORD: string; USERNAME: string } = (
role: string,
tenantId: string,
) => {
const {
API_URL,
API_KEY,
API_AWS_REGION,
COGNITO_USERNAME_PRACTITIONER,
COGNITO_USERNAME_AUDITOR,
COGNITO_PASSWORD,
COGNITO_CLIENT_ID,
COGNITO_USERNAME_PRACTITIONER_ANOTHER_TENANT,
MULTI_TENANCY_ENABLED,
} = process.env;

if (COGNITO_USERNAME_PRACTITIONER === undefined) {
throw new Error('COGNITO_USERNAME_PRACTITIONER environment variable is not defined');
}
if (COGNITO_USERNAME_AUDITOR === undefined) {
throw new Error('COGNITO_USERNAME_AUDITOR environment variable is not defined');
}
if (COGNITO_PASSWORD === undefined) {
throw new Error('COGNITO_PASSWORD environment variable is not defined');
}

if (MULTI_TENANCY_ENABLED === 'true') {
if (COGNITO_USERNAME_PRACTITIONER_ANOTHER_TENANT === undefined) {
throw new Error('COGNITO_USERNAME_PRACTITIONER_ANOTHER_TENANT environment variable is not defined');
}
}

// for simplicity the different test users have the same password
const password = COGNITO_PASSWORD;
let username: string | undefined;
switch (role) {
case 'practitioner':
if (tenantId === undefined || tenantId === DEFAULT_TENANT_ID) {
username = COGNITO_USERNAME_PRACTITIONER;
break;
}
if (tenantId === 'tenant2') {
username = COGNITO_USERNAME_PRACTITIONER_ANOTHER_TENANT!;
break;
}
break;
case 'auditor':
username = COGNITO_USERNAME_AUDITOR;
break;
default:
break;
}

if (username === undefined) {
throw new Error('Could not find a username. Did you set up the integ tests correctly');
}

return {
USERNAME: username,
PASSWORD: password,
};
};

export const getFhirClient = async ({
role = 'practitioner',
providedAccessToken,
tenant = 'tenant1',
}: { role?: 'auditor' | 'practitioner'; providedAccessToken?: string; tenant?: string } = {}) => {
Copy link
Contributor

@rsmayda rsmayda Jul 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is returning an axios instance not this custom type right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not the return type of the function. That's the type of the parameters(It gets a bit tricky due to the defaults and destructuring of params). The return type of the function is implicitly Promise<AxiosInstance>

export const getFhirClient = async ({
    role = 'practitioner',
    providedAccessToken,
    tenant = 'tenant1',
}: { role?: 'auditor' | 'practitioner'; providedAccessToken?: string; tenant?: string } = {}) => {
...

It could be specified as:

export const getFhirClient = async ({
    role = 'practitioner',
    providedAccessToken,
    tenant = 'tenant1',
}: { role?: 'auditor' | 'practitioner'; providedAccessToken?: string; tenant?: string } = {}): Promise<AxiosInstance> => {
...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh sorry yep that is right!

const { API_URL, API_KEY, API_AWS_REGION, COGNITO_CLIENT_ID, MULTI_TENANCY_ENABLED } = process.env;
if (API_URL === undefined) {
throw new Error('API_URL environment variable is not defined');
}
Expand All @@ -34,37 +89,46 @@ export const getFhirClient = async (
if (COGNITO_CLIENT_ID === undefined) {
throw new Error('COGNITO_CLIENT_ID environment variable is not defined');
}
if (COGNITO_USERNAME_PRACTITIONER === undefined) {
throw new Error('COGNITO_USERNAME_PRACTITIONER environment variable is not defined');
}
if (COGNITO_USERNAME_AUDITOR === undefined) {
throw new Error('COGNITO_USERNAME_AUDITOR environment variable is not defined');
}
if (COGNITO_PASSWORD === undefined) {
throw new Error('COGNITO_PASSWORD environment variable is not defined');
}

AWS.config.update({ region: API_AWS_REGION });
const Cognito = new AWS.CognitoIdentityServiceProvider();

const accessToken =
const IdToken =
providedAccessToken ??
(
await Cognito.initiateAuth({
ClientId: COGNITO_CLIENT_ID,
AuthFlow: 'USER_PASSWORD_AUTH',
AuthParameters: {
USERNAME: role === 'auditor' ? COGNITO_USERNAME_AUDITOR : COGNITO_USERNAME_PRACTITIONER,
PASSWORD: COGNITO_PASSWORD,
},
AuthParameters: getAuthParameters(role, tenant),
}).promise()
).AuthenticationResult!.AccessToken;
).AuthenticationResult!.IdToken!;

let baseURL = API_URL;

if (MULTI_TENANCY_ENABLED === 'true') {
const decoded = decode(IdToken) as any;
let tenantIdFromToken;
if (!decoded) {
// This only happens when the jwt token is invalid.
tenantIdFromToken = DEFAULT_TENANT_ID;
} else {
tenantIdFromToken = decoded['custom:tenantId'];
}
if (!tenantIdFromToken) {
throw new Error(
'Attempted to run multi-tenancy tests but the tenantId is not present in the token. Did you set up the integ tests correctly?',
);
}

baseURL = `${API_URL}/tenant/${tenantIdFromToken}`;
}

return axios.create({
headers: {
'x-api-key': API_KEY,
Authorization: `Bearer ${accessToken}`,
Authorization: `Bearer ${IdToken}`,
},
baseURL: API_URL,
baseURL,
});
};

Expand Down
Loading