Skip to content
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
38 changes: 38 additions & 0 deletions app/controllers/team.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,41 @@ exports.removeMember = async (req, res) => {
return handleError(err, res);
}
};

const CANDIDATE_LIMIT = 10;

exports.searchMemberCandidates = async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
const { teamId } = req.params;
// The validator limits q to [A-Za-z0-9-_], none of which is a regex metacharacter, so it
// can go into the pattern as is. Usernames are stored lowercase.
const term = req.query.q.trim().toLowerCase();

try {
if (!authMiddleware.hasTeamLeadAccess(req.user, teamId)) {
throw new ForbiddenError('Forbidden. You do not have permission to add members to this team.');
}
const team = await Team.findById(teamId).select('_id').lean();
if (!team) {
throw new NotFoundError(`Team not found with id ${teamId}`);
}
const filter = {
username: new RegExp(term),
teams: { $ne: teamId },
};
// Same rule as addMember: only an admin can add team leads or admins.
if (req.user.role !== 'admin') {
filter.role = 'user';
}
const candidates = await User.find(filter, '_id username role')
.sort('username')
.limit(CANDIDATE_LIMIT)
.lean();
return res.status(200).send(candidates);
} catch (err) {
return handleError(err, res);
}
};
18 changes: 17 additions & 1 deletion app/routes/team.routes.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { check, param } = require('express-validator');
const { check, param, query } = require('express-validator');
const teamController = require('../controllers/team.controller.js');
const authMiddleware = require('../utils/auth-middleware.js');

Expand Down Expand Up @@ -52,6 +52,22 @@ module.exports = (app, path) => {
param('teamId').isMongoId(),
], teamController.getMembers);

// Predictive search for the "add member" box. Deliberately narrow, because it lets a team
// lead look up accounts they otherwise cannot list: it needs at least three characters of
// the username, returns a handful of matches, and (for a team lead) only users they could
// add anyway - never admins, team leads, or people already in the team.
app.get(`${path}/team/:teamId/members/candidates`, authMiddleware.preventTokenAuth, [
param('teamId').isMongoId(),
query('q')
.exists({ checkFalsy: true })
.isString()
.trim()
.isLength({ min: 3, max: 50 })
.withMessage('q must be between 3 and 50 characters.')
.matches(/^[A-Za-z0-9-_]+$/)
.withMessage('q must only contain letters, numbers, hyphens or underscores.'),
], teamController.searchMemberCandidates);

app.post(`${path}/team/:teamId/members`, authMiddleware.preventTokenAuth, [
param('teamId').isMongoId(),
check('username')
Expand Down
92 changes: 92 additions & 0 deletions swagger/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,98 @@
}
}
},
"/team/{teamId}/members/candidates": {
"parameters": [
{
"name": "teamId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Team"
],
"summary": "Search for users that can be added to a team",
"description": "Session authentication only - API tokens (x-api-key) are rejected on this endpoint with a 403. Callable by an admin or a team lead of this team. Powers the type-ahead on the add-member box. Matches part of the username (case-insensitive), returns at most 10 users ordered by username, and leaves out users who are already in the team. For a team lead only users with the 'user' role are returned, since those are the only ones a lead can add; an admin also sees team leads and admins.",
"parameters": [
{
"name": "q",
"in": "query",
"required": true,
"description": "Part of the username: 3 to 50 letters, numbers, hyphens or underscores.",
"schema": {
"type": "string",
"minLength": 3,
"maxLength": 50
}
}
],
"responses": {
"200": {
"description": "The matching users (possibly none).",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"properties": {
"_id": {
"type": "string"
},
"username": {
"type": "string"
},
"role": {
"type": "string",
"enum": [
"admin",
"user",
"team_lead"
]
}
}
}
}
}
}
},
"403": {
"description": "Caller is not an admin or a team lead of this team",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DefaultResponse"
}
}
}
},
"404": {
"description": "Team not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DefaultResponse"
}
}
}
},
"422": {
"description": "Validation error (q missing, too short or with unsupported characters)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DefaultResponse"
}
}
}
}
}
}
},
"/team/{teamId}/members/{userId}": {
"parameters": [
{
Expand Down
72 changes: 72 additions & 0 deletions test/team-members.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,78 @@ describe('Team members, last login and build trigger Tests', () => {
});
});

describe('GET /team/:teamId/members/candidates', () => {
const search = (agent, teamId, q) => agent
.get(`${baseUrl}team/${teamId}/members/candidates`)
.query({ q });

it('finds users to add by part of their username, case-insensitively', async () => {
const res = await search(leadAgent, team._id, 'CAND').expect(200);
res.body.map((u) => u.username).should.eql([candidate.username]);
Object.keys(res.body[0]).sort().should.eql(['_id', 'role', 'username']);
});

it('matches in the middle of a username, not only the start', async () => {
const res = await search(leadAgent, team._id, 'tm-out').expect(200);
res.body.map((u) => u.username).should.eql([outsider.username]);
});

it('offers a team lead only plain users who are not already in the team', async () => {
const res = await search(leadAgent, team._id, `${PREFIX}`).expect(200);
const names = res.body.map((u) => u.username);
names.should.containDeep([candidate.username, outsider.username]);
// already a member / a team lead of this team / a team lead elsewhere
names.should.not.containEql(member.username);
names.should.not.containEql(lead.username);
names.should.not.containEql(teamLead2.username);
names.should.not.containEql(otherLead.username);
res.body.forEach((u) => u.role.should.equal('user'));
});

it('also offers team leads to an admin, who may add them', async () => {
const res = await search(adminAgent, team._id, `${PREFIX}`).expect(200);
const names = res.body.map((u) => u.username);
names.should.containDeep([candidate.username, outsider.username, otherLead.username]);
names.should.not.containEql(member.username);
});

it('returns an empty list when nothing matches', async () => {
const res = await search(leadAgent, team._id, 'zzzzzz-no-such-user').expect(200);
res.body.should.eql([]);
});

it('caps the number of suggestions', async () => {
const bulk = await Promise.all(Array.from({ length: 12 }, (_, i) => createUser(`bulk-${String(i).padStart(2, '0')}`, 'user')));
try {
const res = await search(leadAgent, team._id, 'tm-bulk').expect(200);
res.body.should.have.length(10);
} finally {
await User.deleteMany({ _id: { $in: bulk.map((u) => u._id) } });
}
});

it('respond with 422 for fewer than three characters, or characters outside a username', async () => {
await search(leadAgent, team._id, 'ca').expect(422);
await search(leadAgent, team._id, '').expect(422);
await search(leadAgent, team._id, 'a.*').expect(422);
await leadAgent.get(`${baseUrl}team/${team._id}/members/candidates`).expect(422);
});

it('respond with 403 for a plain member and for a lead of another team', async () => {
await search(memberAgent, team._id, 'cand').expect(403);
await search(leadAgent, otherTeam._id, 'cand').expect(403);
});

it('respond with 403 when called with an API token, and 404 for an unknown team', async () => {
await request(app)
.get(`${baseUrl}team/${team._id}/members/candidates`)
.query({ q: 'cand' })
.set('x-api-key', leadToken)
.expect(403);
await search(adminAgent, '507f1f77bcf86cd799439011', 'cand').expect(404);
});
});

describe('POST /team/:teamId/members', () => {
it('lets a team lead add a user by username (case-insensitively)', async () => {
const res = await leadAgent
Expand Down
Loading