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

Commit

Permalink
test: add multi-tenancy integ tests (#387)
Browse files Browse the repository at this point in the history
  • Loading branch information
carvantes authored Jul 23, 2021
1 parent d34f7bd commit ac8ba55
Show file tree
Hide file tree
Showing 6 changed files with 308 additions and 29 deletions.
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', () => {
// 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', () => {
let client: AxiosInstance;
let clientForAnotherTenant: AxiosInstance;
beforeAll(async () => {
client = await getFhirClient({ tenant: 'tenant1' });
clientForAnotherTenant = await getFhirClient({ tenant: 'tenant2' });
});

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: '*' } },
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 },
});
});
});
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 } = {}) => {
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

0 comments on commit ac8ba55

Please sign in to comment.