diff --git a/forge/db/models/Application.js b/forge/db/models/Application.js index 4c7ed6ce6c..c98ce51373 100644 --- a/forge/db/models/Application.js +++ b/forge/db/models/Application.js @@ -47,7 +47,7 @@ module.exports = { ] }) }, - byTeam: async (teamIdOrHash, { query = null, includeInstances = false, includeApplicationDevices = false, includeInstanceStorageFlow = false, associationsLimit = null, includeApplicationSummary = false } = {}) => { + byTeam: async (teamIdOrHash, { query = null, applicationId = null, includeInstances = false, includeApplicationDevices = false, includeInstanceStorageFlow = false, associationsLimit = null, includeApplicationSummary = false } = {}) => { let id = teamIdOrHash if (typeof teamIdOrHash === 'string') { id = M.Team.decodeHashid(teamIdOrHash) @@ -120,13 +120,24 @@ module.exports = { const queryObject = { include: includes } - if (query) { - queryObject.where = { + const queryWheres = [] + if (applicationId) { + if (typeof applicationId === 'string') { + applicationId = M.Application.decodeHashid(applicationId) + } + queryWheres.push({ id: applicationId }) + } else if (query) { + queryWheres.push({ [Op.or]: [ where(fn('lower', col('Application.name')), { [Op.like]: `%${query.toLowerCase()}%` }), where(fn('lower', col('Application.description')), { [Op.like]: `%${query.toLowerCase()}%` }) ] - } + }) + } + if (queryWheres.length === 1) { + queryObject.where = queryWheres[0] + } else if (queryWheres.length > 1) { + queryObject.where = { [Op.and]: queryWheres } } if (includeApplicationSummary) { diff --git a/forge/db/models/Device.js b/forge/db/models/Device.js index 130d8fd483..c00eec7ecc 100644 --- a/forge/db/models/Device.js +++ b/forge/db/models/Device.js @@ -305,7 +305,7 @@ module.exports = { ] }) }, - byTeam: async (teamIdOrHash, { query = null } = {}) => { + byTeam: async (teamIdOrHash, { query = null, deviceId = null } = {}) => { let teamId = teamIdOrHash if (typeof teamId === 'string') { teamId = M.Team.decodeHashid(teamId) @@ -313,8 +313,9 @@ module.exports = { const queryObject = { where: { [Op.and]: [{ TeamId: teamId }] } } - - if (query) { + if (deviceId) { + queryObject.where[Op.and].push({ id: deviceId }) + } else if (query) { queryObject.where[Op.and].push({ [Op.or]: [ where(fn('lower', col('Device.name')), { [Op.like]: `%${query.toLowerCase()}%` }), diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js index 2acf213983..0f26f09813 100644 --- a/forge/db/models/Project.js +++ b/forge/db/models/Project.js @@ -469,7 +469,7 @@ module.exports = { include }) }, - byTeam: async (teamIdOrHash, { query = null, includeAssociations = true, includeSettings = false } = {}) => { + byTeam: async (teamIdOrHash, { query = null, instanceId = null, includeAssociations = true, includeSettings = false } = {}) => { let teamId = teamIdOrHash if (typeof teamId === 'string') { teamId = M.Team.decodeHashid(teamId) @@ -512,7 +512,9 @@ module.exports = { include } - if (query) { + if (instanceId) { + queryObject.where = { id: instanceId } + } else if (query) { queryObject.where = where( fn('lower', col('Project.name')), { [Op.like]: `%${query.toLowerCase()}%` } diff --git a/forge/routes/api/search.js b/forge/routes/api/search.js index c6c617ebee..ff387471c2 100644 --- a/forge/routes/api/search.js +++ b/forge/routes/api/search.js @@ -52,51 +52,87 @@ module.exports = async function (app) { const membership = await request.session.User.getTeamMembership(teamId) // Check user has access to this team - either admin or at least Viewer role if (request.session.User.admin || app.hasPermission(membership, 'team:search')) { - // Now do the search - const applicationSearchPromise = app.db.models.Application.byTeam( - teamId, - { - query, - includeApplicationSummary: true - } - ) - const instanceSearchPromise = app.db.models.Project.byTeam( - teamId, - { - query, - includeAssociations: false - } - ) - const deviceSearchPromise = app.db.models.Device.byTeam( - teamId, - { - query - } - ) + let applicationSearchPromise = Promise.resolve([]) + let instanceSearchPromise = Promise.resolve([]) + let deviceSearchPromise = Promise.resolve([]) + + // first check to see if the query is an ID + const applicationId = app.db.models.Application.decodeHashid(query)?.[0] + const deviceId = app.db.models.Device.decodeHashid(query)?.[0] + const isAppId = typeof applicationId === 'number' + const isDeviceId = typeof deviceId === 'number' + const isInstanceId = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.test(query) + + // now search for the query term in the team + if (isAppId) { + applicationSearchPromise = app.db.models.Application.byTeam( + teamId, + { + applicationId, + includeApplicationSummary: true + } + ) + } else if (isInstanceId) { + instanceSearchPromise = app.db.models.Project.byTeam( + teamId, + { + instanceId: query, + includeAssociations: false + } + ) + } else if (isDeviceId) { + deviceSearchPromise = app.db.models.Device.byTeam( + teamId, + { + deviceId + } + ) + } else { + applicationSearchPromise = app.db.models.Application.byTeam( + teamId, + { + query, + includeApplicationSummary: true + } + ) + instanceSearchPromise = app.db.models.Project.byTeam( + teamId, + { + query, + includeAssociations: false + } + ) + deviceSearchPromise = app.db.models.Device.byTeam( + teamId, + { + query + } + ) + } const results = await Promise.all([ applicationSearchPromise, instanceSearchPromise, deviceSearchPromise ]) const rr = [ - ...results[0].map(application => { + ...(results[0].map(application => { return { object: 'application', ...app.db.views.Application.applicationSummary(application, { detailed: true }) } - }), - ...results[1].map(instance => { + })) || [], + ...(results[1].map(instance => { return { object: 'instance', ...app.db.views.Project.projectSummary(instance) } - }), - ...results[2].devices.map(device => { + })) || [], + ...(results[2].devices?.map(device => { return { object: 'device', ...app.db.views.Device.deviceSummary(device) } - }) + })) || [] ] reply.send({ diff --git a/test/unit/forge/routes/api/search_spec.js b/test/unit/forge/routes/api/search_spec.js index 1becc14e05..9a1b910219 100644 --- a/test/unit/forge/routes/api/search_spec.js +++ b/test/unit/forge/routes/api/search_spec.js @@ -1,3 +1,4 @@ +const Sinon = require('sinon') const should = require('should') // eslint-disable-line const setup = require('../setup') @@ -49,9 +50,9 @@ describe('Search API', function () { TestObjects.AppOne = await app.factory.createApplication({ name: 'Application One', description: 'app-one-desc' }, TestObjects.BTeam) - const ia1abc = await app.factory.createInstance({ name: 'instance-app-one-abc' }, TestObjects.AppOne, app.stack, app.template, app.projectType, { start: false }) + TestObjects.App1Instance1 = await app.factory.createInstance({ name: 'instance-app-one-abc' }, TestObjects.AppOne, app.stack, app.template, app.projectType, { start: false }) await app.factory.createInstance({ name: 'instance-app-one-def' }, TestObjects.AppOne, app.stack, app.template, app.projectType, { start: false }) - await app.factory.createDevice({ name: 'device-app-one-instance-one-ghi' }, TestObjects.BTeam, ia1abc) + TestObjects.App1Instance1Device1 = await app.factory.createDevice({ name: 'device-app-one-instance-one-ghi' }, TestObjects.BTeam, TestObjects.App1Instance1) await app.factory.createDevice({ name: 'device-app-one-abc' }, TestObjects.BTeam, null, TestObjects.AppOne) await app.factory.createDevice({ name: 'device-app-one-def' }, TestObjects.BTeam, null, TestObjects.AppOne) @@ -74,6 +75,10 @@ describe('Search API', function () { await app.close() }) + afterEach(function () { + Sinon.restore() + }) + async function login (username, password) { const response = await app.inject({ method: 'POST', @@ -194,6 +199,75 @@ describe('Search API', function () { result.results[0].should.have.property('object', 'device') }) + it('search by application id returns only one application & does not query other tables', async function () { + // bob - team member + Sinon.spy(app.db.models.Application, 'byTeam') + Sinon.spy(app.db.models.Project, 'byTeam') + Sinon.spy(app.db.models.Device, 'byTeam') + + const response = await search({ team: TestObjects.BTeam.hashid, query: TestObjects.AppOne.hashid }, TestObjects.tokens.bob) + response.statusCode.should.equal(200) + const result = response.json() + result.count.should.equal(1) + result.results.should.have.length(1) + result.results[0].should.have.property('object', 'application') + + app.db.models.Application.byTeam.called.should.be.true() + app.db.models.Project.byTeam.called.should.be.false() + app.db.models.Device.byTeam.called.should.be.false() + + // ensure query was not passed to byTeam & that applicationId was passed + const args = app.db.models.Application.byTeam.getCall(0).args + args[1].should.not.have.property('query') + args[1].should.have.property('applicationId', TestObjects.AppOne.id) // actual id, not hashid + }) + + it('search by instance id returns only one instance & does not query other tables', async function () { + // bob - team member + Sinon.spy(app.db.models.Application, 'byTeam') + Sinon.spy(app.db.models.Project, 'byTeam') + Sinon.spy(app.db.models.Device, 'byTeam') + + const response = await search({ team: TestObjects.BTeam.hashid, query: TestObjects.App1Instance1.id }, TestObjects.tokens.bob) + response.statusCode.should.equal(200) + const result = response.json() + result.count.should.equal(1) + result.results.should.have.length(1) + result.results[0].should.have.property('object', 'instance') + + app.db.models.Application.byTeam.called.should.be.false() + app.db.models.Project.byTeam.called.should.be.true() + app.db.models.Device.byTeam.called.should.be.false() + + // ensure query was not passed to byTeam & that instanceId was passed + const args = app.db.models.Project.byTeam.getCall(0).args + args[1].should.not.have.property('query') + args[1].should.have.property('instanceId', TestObjects.App1Instance1.id) + }) + + it('search by device id returns only one device & does not query other tables', async function () { + // bob - team member + Sinon.spy(app.db.models.Application, 'byTeam') + Sinon.spy(app.db.models.Project, 'byTeam') + Sinon.spy(app.db.models.Device, 'byTeam') + + const response = await search({ team: TestObjects.BTeam.hashid, query: TestObjects.App1Instance1Device1.hashid }, TestObjects.tokens.bob) + response.statusCode.should.equal(200) + const result = response.json() + result.count.should.equal(1) + result.results.should.have.length(1) + result.results[0].should.have.property('object', 'device') + + app.db.models.Application.byTeam.called.should.be.false() + app.db.models.Project.byTeam.called.should.be.false() + app.db.models.Device.byTeam.called.should.be.true() + + // ensure query was not passed to byTeam & that deviceId was passed + const args = app.db.models.Device.byTeam.getCall(0).args + args[1].should.not.have.property('query') + args[1].should.have.property('deviceId', TestObjects.App1Instance1Device1.id) + }) + it('search with blank query returns nothing', async function () { // bob - team member const response = await search({ team: TestObjects.BTeam.hashid, query: '' }, TestObjects.tokens.bob)