Skip to content
Open
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
1 change: 1 addition & 0 deletions src/controllers/locations.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const locationAssociations = {
include: [
{
model: models.Organization,
attributes: models.Organization.getPublicAttributes(),
include: [models.Phone],
},
models.Phone,
Expand Down
57 changes: 52 additions & 5 deletions src/controllers/organizations.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,26 @@ import Joi from 'joi';
import organizationSchemas from './validation/organizations';
import models from '../models';
import { updateInstance, createInstance } from '../services/data-changes';
import { NotFoundError } from '../utils/errors';
import { ForbiddenError, NotFoundError } from '../utils/errors';

const normalizeEmail = (email) => {
if (email == null) {
return email;
}

const trimmedEmail = email.trim();
return trimmedEmail || null;
};

const assertUserCanAccessOrganization = (req, organizationId) => {
if (req.userIsAdmin) {
return;
}

if (!req.userOrganizationIds || !req.userOrganizationIds.includes(organizationId)) {
throw new ForbiddenError('Not authorized to view this organization');
}
};

export default {
find: async (req, res, next) => {
Expand All @@ -12,7 +31,9 @@ export default {
const { searchString } = req.query;
const filterParameters = searchString ? { searchString: searchString.trim() } : {};

const organizations = await models.Organization.findMatching(filterParameters);
const organizations = await models.Organization.findMatching(filterParameters, 10, {
attributes: models.Organization.getPublicAttributes(),
});
res.send(organizations);
} catch (err) {
next(err);
Expand All @@ -26,14 +47,17 @@ export default {
const {
name,
description,
email: rawEmail,
url,
metadata,
} = req.body;
const email = normalizeEmail(rawEmail);

const modelCreateFunction = models.Organization.create.bind(models.Organization);
const createdOrganization = await createInstance(req.user, modelCreateFunction, {
name,
description,
email,
url,
}, { metadata });

Expand All @@ -48,18 +72,23 @@ export default {
await Joi.validate(req, organizationSchemas.update, { allowUnknown: true });

const { organizationId } = req.params;
assertUserCanAccessOrganization(req, organizationId);

const organization = await models.Organization.findByPk(organizationId);
if (!organization) {
throw new NotFoundError('Organization not found');
}

const editableFields = ['name', 'description', 'url'];
const { metadata, ...updateParams } = req.body;
const editableFields = ['name', 'description', 'email', 'url'];
const { metadata, email, ...updateParams } = req.body;
const normalizedUpdateParams = email === undefined ? updateParams : {
...updateParams,
email: normalizeEmail(email),
};
await updateInstance(
req.user,
organization,
updateParams,
normalizedUpdateParams,
{ fields: editableFields, metadata },
);

Expand Down Expand Up @@ -104,4 +133,22 @@ export default {
next(err);
}
},

get: async (req, res, next) => {
try {
await Joi.validate(req, organizationSchemas.get, { allowUnknown: true });

const { organizationId } = req.params;
assertUserCanAccessOrganization(req, organizationId);

const organization = await models.Organization.findByPk(organizationId);
if (!organization) {
throw new NotFoundError('Organization not found');
}

res.send(organization);
} catch (err) {
next(err);
}
},
};
9 changes: 9 additions & 0 deletions src/controllers/validation/organizations.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const updateMetadataSchema = Joi.object().keys({
source: Joi.string(),
lastUpdated: Joi.date().iso(),
});
const emailSchema = Joi.string().trim().email().allow('', null);

export default {
find: {
Expand All @@ -16,6 +17,7 @@ export default {
body: Joi.object().keys({
name: Joi.string().required(),
description: Joi.string(),
email: emailSchema,
url: Joi.string(),
metadata: updateMetadataSchema,
}).required(),
Expand All @@ -28,11 +30,18 @@ export default {
body: Joi.object().keys({
name: Joi.string(),
description: Joi.string().allow(''),
email: emailSchema,
url: Joi.string().allow(''),
metadata: updateMetadataSchema,
}).required(),
},

get: {
params: Joi.object().keys({
organizationId: Joi.string().guid().required(),
}).required(),
},

getLocations: {
params: Joi.object().keys({
organizationId: Joi.string().guid().required(),
Expand Down
5 changes: 4 additions & 1 deletion src/models/location.js
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,10 @@ module.exports = (sequelize, DataTypes, Op) => {
],
},
] : [
sequelize.models.Organization,
{
model: sequelize.models.Organization,
attributes: sequelize.models.Organization.getPublicAttributes(),
},
sequelize.models.EventRelatedInfo,
{
model: sequelize.models.Service,
Expand Down
8 changes: 6 additions & 2 deletions src/models/organization.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,20 @@ module.exports = (sequelize, DataTypes, Op) => {
Organization.hasMany(models.Phone, { foreignKey: 'organization_id' });
};

Organization.findMatching = (filterParameters, limit = 10) => {
Organization.findMatching = (filterParameters, limit = 10, queryOptions = {}) => {
const { searchString } = filterParameters;

const where = {};
if (searchString) {
where.name = { [Op.iLike]: `%${searchString}%` };
}

return Organization.findAll({ limit, where });
return Organization.findAll({ limit, where, ...queryOptions });
};

Organization.getPublicAttributes = () => ({
exclude: ['email'],
});

return Organization;
};
1 change: 1 addition & 0 deletions src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {

export default (app) => {
app.get('/organizations', organizations.find);
app.get('/organizations/:organizationId', getUser, organizations.get);
app.post('/organizations', getUser, dataEntryAuth, organizations.create);
app.patch('/organizations/:organizationId', getUser, dataEntryAuth, organizations.update);
app.get('/organizations/:organizationId/locations', organizations.getLocations);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ Object {
"Organization": Object {
"Phones": Object {},
"description": "An organization meant for testing purposes.",
"email": null,
"name": "The Test Org",
"url": "www.streetlives.com",
},
Expand Down
16 changes: 16 additions & 0 deletions test/integration/create-organization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ describe('create organization', () => {
expect(dbOrg).toHaveProperty('url', orgParams.url);
});

it('should trim and persist a valid email address', async () => {
await request(app)
.post('/organizations')
.send({ ...orgParams, name: 'Org With Email', email: ' intake@streetlives.org ' })
.expect(201);

const dbOrg = await models.Organization.findOne({ where: { name: 'Org With Email' } });
expect(dbOrg).toHaveProperty('email', 'intake@streetlives.org');
});

it('should reject an invalid email address', () =>
request(app)
.post('/organizations')
.send({ ...orgParams, name: 'Org With Invalid Email', email: 'not-an-email' })
.expect(400));

describe('when no custom metadata is specified', () => {
it('should create metadata with current time as the last action date', async () => {
const startTime = Date.now();
Expand Down
16 changes: 16 additions & 0 deletions test/integration/find-locations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('find locations', () => {
{
name: 'The Test Org',
description: 'An organization meant for testing purposes.',
email: 'contact@streetlives.org',
Services: [
{
name: 'A specific offering',
Expand Down Expand Up @@ -209,6 +210,21 @@ describe('find locations', () => {
.expect(200)
.then(expectMatchNearbyLocations));

it('should not expose organization email in public location results', () =>
request(app)
.get('/locations')
.query({
latitude: originLatitude,
longitude: originLongitude,
radius,
})
.expect(200)
.then((res) => {
res.body.forEach((returnedLocation) => {
expect(returnedLocation.Organization).not.toHaveProperty('email');
});
}));

it('should not return locations marked "hidden from search" (meant for comments only)', () =>
request(app)
.get('/locations')
Expand Down
2 changes: 2 additions & 0 deletions test/integration/get-location-info.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('get location info', () => {
{
name: 'The Test Org',
description: 'An organization meant for testing purposes.',
email: 'contact@streetlives.org',
url: 'www.streetlives.com',
Services: [{
name: 'A specific offering',
Expand Down Expand Up @@ -86,6 +87,7 @@ describe('get location info', () => {
.get(`/locations/${location.id}`)
.expect(200)
.then((res) => {
expect(res.body.Organization).not.toHaveProperty('email');
const strippedFields = stripTimestampsAndIds(res.body);
expect(strippedFields).toMatchSnapshot();
}));
Expand Down
111 changes: 111 additions & 0 deletions test/integration/get-organizations.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* @jest-environment node
*/

import express from 'express';
import request from 'supertest';
import app from '../../src/app';
import models from '../../src/models';

describe('get organizations', () => {
let organization;

const buildAppWithClaims = (claims) => {
const appWithClaims = express();
appWithClaims.use((req, res, next) => {
req.apiGateway = {
event: {
requestContext: {
authorizer: {
claims,
},
},
},
};
next();
});
appWithClaims.use(app);
return appWithClaims;
};

const buildProductionApp = () => {
let productionApp;
const originalNodeEnv = process.env.NODE_ENV;

jest.isolateModules(() => {
process.env.NODE_ENV = 'production';
productionApp = require('../../src/app').default; // eslint-disable-line global-require
});

process.env.NODE_ENV = originalNodeEnv;
return productionApp;
};

beforeEach(async () => {
organization = await models.Organization.create({
name: 'Organization With Email',
description: 'An organization used to test public/private payloads.',
email: 'contact@streetlives.org',
url: 'www.streetlives.com',
});
});

afterEach(() => models.Organization.destroy({ where: {} }));

it('should not expose email in the public organizations list', async () => {
const res = await request(app)
.get('/organizations')
.expect(200);

const returnedOrganization = res.body.find(org => org.id === organization.id);
expect(returnedOrganization).toBeDefined();
expect(returnedOrganization).not.toHaveProperty('email');
});

it('should return 401 when no authenticated user is present in production', () =>
request(buildProductionApp())
.get(`/organizations/${organization.id}`)
.expect(401));

it('should return 403 when an anonymous test user fetches a specific organization', () =>
request(app)
.get(`/organizations/${organization.id}`)
.expect(403));

it('should return 403 when the authenticated user is not authorized for the organization', () => {
const appWithClaims = buildAppWithClaims({
sub: 'user-id',
'custom:orgs': '11111111-1111-1111-1111-111111111111',
});

return request(appWithClaims)
.get(`/organizations/${organization.id}`)
.expect(403);
});

it('should return email for an organization member', async () => {
const appWithClaims = buildAppWithClaims({
sub: 'user-id',
'custom:orgs': organization.id,
});

const res = await request(appWithClaims)
.get(`/organizations/${organization.id}`)
.expect(200);

expect(res.body).toHaveProperty('email', organization.email);
});

it('should return email when fetching a specific organization as an admin', async () => {
const appWithClaims = buildAppWithClaims({
sub: 'admin-id',
'cognito:groups': 'StreetlivesAdmins',
});

const res = await request(appWithClaims)
.get(`/organizations/${organization.id}`)
.expect(200);

expect(res.body).toHaveProperty('email', organization.email);
});
});
Loading