diff --git a/lib/models/observation_query_builder.js b/lib/models/observation_query_builder.js index 9b739041..1bb40da8 100644 --- a/lib/models/observation_query_builder.js +++ b/lib/models/observation_query_builder.js @@ -47,145 +47,202 @@ ObservationQueryBuilder.applyLookupRules = async req => { } }; -// Builds place filters for an authenticated user that is filtering by place. -// Massive complexity brought to you by trusting collection projects -ObservationQueryBuilder.placeFilterForUser = async ( req, params ) => { - const publicPlaceFilter = esClient.termFilter( "place_ids.keyword", params.place_id ); - const privatePlaceFilter = esClient.termFilter( "private_place_ids.keyword", params.place_id ); - // Current user should see obs that match the regular place filter OR obs - // that they created and have private coordinates that fall in the query - // place - const placeFilterForUser = { +// Given a public and private version of a filter, ensure that only users with +// permission to view private coordinates in the given context will use them, +// and all other user contexts will use the public filter +ObservationQueryBuilder.locationBasedFilterForUser = async ( req, pubicFilter, privateFilter ) => { + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( req ); + + const filterConditions = []; + // there are users that trust the logged-in user to view private coordinates + // of all observations, so include include the private filter with those users + if ( !_.isEmpty( usersTrustingForAny ) ) { + filterConditions.push( { + bool: { + must: [ + privateFilter, + { terms: { "user.id.keyword": usersTrustingForAny } } + ] + } + } ); + } + + // there are users that trust the logged-in users to view private coordinates + // of observations obscured due to taxon geoprivacy, so include the private + // filter with those users with the additional taxon_geoprivacy component + if ( !_.isEmpty( usersTrustingForTaxon ) ) { + filterConditions.push( { + bool: { + must: [ + privateFilter, + // we are focusing only on obs obscured by taxon geoprivacy... + { terms: { taxon_geoprivacy: ["obscured", "private"] } }, + { terms: { "user.id.keyword": usersTrustingForTaxon } } + ], + // ... but not those obscured by personal geoprivacy + must_not: [ + { exists: { field: "geoprivacy" } } + ] + } + } ); + } + + // no users trust the logged-in user in this context, so only use the public filter + if ( _.isEmpty( filterConditions ) ) { + return pubicFilter; + } + + // include the public filter with the trusted private filters + filterConditions.push( pubicFilter ); + return { bool: { - should: [ - publicPlaceFilter, - { - bool: { - must: [ - privatePlaceFilter, - { term: { "user.id.keyword": req.userSession.user_id } } - ] - } - } - ] + should: filterConditions } }; - // req._collectionProject is a special attribute set when we are getting an - // obs query from collection project search params. See - // projectRulesQueryFilters - // If we're not doing complex project logic, just return that relatively - // simple filter for the signed in user - if ( !req._collectionProject ) { - return placeFilterForUser; - } - // We are building a query in the context of an umbrella project, but the umbrella - // hasn't enabled trusting, so skip the complex logic - if ( req._umbrellaProject && !req._umbrellaProject.prefers_user_trust ) { - return placeFilterForUser; - } - // We are building a query in the context of an collection project, but the collection - // hasn't enabled trusting, so skip the complex logic - if ( !req._collectionProject.prefers_user_trust && !req._umbrellaProject ) { - return placeFilterForUser; - } - // If we're filtering for a project, reset to the public filter and grant - // allowances based on curatorship and trusting member status - placeFilterForUser.bool.should = [publicPlaceFilter]; - const usersTrustingProjectForAny = await ProjectUser.usersTrustingProjectFor( - req._collectionProject.id, "any" +}; + +// Builds a bounding box filter for the current user and context +ObservationQueryBuilder.boundsFilterForUser = async ( req, params ) => { + if ( !( params.nelat || params.nelng || params.swlat || params.swlng ) ) { + return null; + } + const bounds = { + nelat: params.nelat, + nelng: params.nelng, + swlat: params.swlat, + swlng: params.swlng + }; + const publicEnvelopeFilter = esClient.envelopeFilter( { + envelope: { + geojson: bounds + } + } ); + const privateEnvelopeFilter = esClient.envelopeFilter( { + envelope: { + private_geojson: bounds + } + } ); + return ObservationQueryBuilder.locationBasedFilterForUser( + req, publicEnvelopeFilter, privateEnvelopeFilter ); - const usersTrustingProjectForTaxon = await ProjectUser.usersTrustingProjectFor( - req._collectionProject.id, "taxon" +}; + +// Builds a place filter fort the current user and context +ObservationQueryBuilder.placeFilterForUser = async ( req, params ) => { + if ( !params.place_id || params.place_id === "any" ) { + return null; + } + + const publicPlaceFilter = esClient.termFilter( "place_ids.keyword", params.place_id ); + const privatePlaceFilter = esClient.termFilter( "private_place_ids.keyword", params.place_id ); + return ObservationQueryBuilder.locationBasedFilterForUser( + req, publicPlaceFilter, privatePlaceFilter ); - let usersTrustingForTaxon = usersTrustingProjectForTaxon; - let usersTrustingForAny = usersTrustingProjectForAny; +}; + +// The overall intent here is +// 1. To grant project curators access to obs by trusting users based on private +// coordinates +// 2. To allow project members to see which observations are "in" or "out" of +// the project based on their trusting status +// We are trying to avoid a situation where a non-member views a project and +// sees their own obscured obs included based on the private coordinates, +// which they might see in an ordinary place search b/c they have permission +// to see their own private stuff, but might be alarmed to see it in the +// context of a project they don't trust. We don't want them to think the +// project curators are seeing that too, b/c they're not. + +// Returns users that trust the logged-in user to view private coordinates of +// their observations in the current query context. Users may trust viewers +// for all obscured coordinates, or only for coordinates obscured due to taxon +// geoprivacy. Massive complexity brought to you by trusting collection projects +ObservationQueryBuilder.contextTrustingUsers = async req => { + if ( !req?.userSession?.user_id ) { + return { + usersTrustingForAny: [], + usersTrustingForTaxon: [] + }; + } + + // req._collectionProject and req._umbrellaProject are a special attributes + // set when we are getting an obs query from collection/umbrella project + // search params. See projectRulesQueryFilters. // When we are building a query in the context of an umbrella project, add the // users who trust the umbrella. So if I trust Umbrella 1 which contains // Collection 1 but I don't trust Collection 1, show my obscured obs in // queries for Umbrella 1 for curators of Umbrella 1 - if ( req._umbrellaProject ) { - const usersTrustingUmbrellaProjectForTaxon = await ProjectUser.usersTrustingProjectFor( - req._umbrellaProject.id, "taxon" - ); - usersTrustingForTaxon = usersTrustingProjectForTaxon.concat( - usersTrustingUmbrellaProjectForTaxon - ); - const usersTrustingUmbrellaProjectForAny = await ProjectUser.usersTrustingProjectFor( - req._umbrellaProject.id, "any" - ); - usersTrustingForAny = usersTrustingProjectForAny.concat( - usersTrustingUmbrellaProjectForAny - ); + const projectContext = req._umbrellaProject || req._collectionProject; + + // If we are not building in the context of a project, then ensure that + // the logged-in user trusts themselves in all cases, and trusts no one else + if ( !projectContext ) { + return { + usersTrustingForAny: [req.userSession.user_id], + usersTrustingForTaxon: [] + }; } - const curatedProjectsIDs = await req.userSession.getCuratedProjectsIDs( ); - const viewerCuratesProject = curatedProjectsIDs - && curatedProjectsIDs.indexOf( req._collectionProject.id ) >= 0; - const viewerTrustsProjectForAny = usersTrustingForAny.includes( - req.userSession.user_id + + // If we are not building in the context of a project that has enabled + // trusting, then no trusting should be allowed, even for the logged-in user. + // For example, if the user is viewing in the context of a project but has + // not allowed the project to access their private coordinates, that user + // should also not see their observations' private coordinates when viewing + // that project + if ( !projectContext.prefers_user_trust ) { + return { + usersTrustingForAny: [], + usersTrustingForTaxon: [] + }; + } + + // Look up users that trust this project with all coordinates, and that trust + // the project for only coordinates obscured due to taxon geoprivacy + const usersTrustingProjectForAny = await ProjectUser.usersTrustingProjectFor( + projectContext.id, "any" ); - const viewerTrustsProjectForTaxon = usersTrustingForTaxon.includes( - req.userSession.user_id + const usersTrustingProjectForTaxon = await ProjectUser.usersTrustingProjectFor( + projectContext.id, "taxon" ); - // The overall intent here is - // 1. To grant project curators access to obs by trusting users based on private coordinates - // 2. To allow project members to see which observations are "in" or "out" of the project based - // on their trusting status - // We are trying to avoid a situation where a non-member views a project and - // sees their own obscured obs included based on the private coordinates, - // which they might see in an ordinary place search b/c they have permission - // to see their own private stuff, but might be alarmed to see it in the - // context of a project they don't trust. We don't want them to think the - // project curators are seeing that too, b/c they're not. - if ( usersTrustingForAny.length > 0 ) { - let userFilterForAny; - if ( viewerCuratesProject ) { - // If the current user curates the specified collection project, they - // should also see observations in that project by all project members who - // trust the project with all coordinates - userFilterForAny = { terms: { "user.id.keyword": usersTrustingForAny } }; - } else if ( viewerTrustsProjectForAny ) { - // If the viewer trusts the project for any, they should see only their - // own obscured obs in the project - userFilterForAny = { term: { "user.id.keyword": req.userSession.user_id } }; - } - if ( userFilterForAny ) { - placeFilterForUser.bool.should.push( { - bool: { - must: [ - privatePlaceFilter, - userFilterForAny - ] - } - } ); - } + + // Check to see if the logged-in user curates the project + const curatedProjectsIDs = await req.userSession.getCuratedProjectsIDs( ); + const viewerCuratesProject = curatedProjectsIDs + && curatedProjectsIDs.indexOf( projectContext.id ) >= 0; + + if ( viewerCuratesProject ) { + // the logged-in user is a curator, so they are trusted by users who have + // opted-in to trusting this project with either all coordinates, or just + // those due to taxon geoprivacy + return { + usersTrustingForAny: usersTrustingProjectForAny, + usersTrustingForTaxon: usersTrustingProjectForTaxon + }; } - // Query logic for taxon-only trust is even more complicated... - if ( usersTrustingForTaxon.length > 0 ) { - let userFilterForTaxon; - // Viewer permissions largeley the same as above - if ( viewerCuratesProject ) { - userFilterForTaxon = { terms: { "user.id.keyword": usersTrustingForTaxon } }; - } else if ( viewerTrustsProjectForTaxon ) { - userFilterForTaxon = { term: { "user.id.keyword": req.userSession.user_id } }; - } - if ( userFilterForTaxon ) { - placeFilterForUser.bool.should.push( { - bool: { - must: [ - privatePlaceFilter, - // taxon-only means we are focusing only on obs obscured by taxon geoprivacy... - { terms: { taxon_geoprivacy: ["obscured", "private"] } }, - userFilterForTaxon - ], - // ... but not those obscured by personal geoprivacy - must_not: [ - { exists: { field: "geoprivacy" } } - ] - } - } ); - } + + // the logged-in user is not a curator, and trusts the project with all coords + if ( usersTrustingProjectForAny.includes( req.userSession.user_id ) ) { + return { + usersTrustingForAny: [req.userSession.user_id], + usersTrustingForTaxon: [] + }; + } + + // the logged-in user is not a curator, and trusts the project with taxon coords + if ( usersTrustingProjectForTaxon.includes( req.userSession.user_id ) ) { + return { + usersTrustingForAny: [], + usersTrustingForTaxon: [req.userSession.user_id] + }; } - return placeFilterForUser; + + // the logged-in user is not a curator, and does not trust the project with any coords + return { + usersTrustingForAny: [], + usersTrustingForTaxon: [] + }; }; ObservationQueryBuilder.reqToElasticQueryComponents = async req => { @@ -692,16 +749,10 @@ ObservationQueryBuilder.reqToElasticQueryComponents = async req => { } if ( params.nelat || params.nelng || params.swlat || params.swlng ) { - searchFilters.push( { - envelope: { - geojson: { - nelat: params.nelat, - nelng: params.nelng, - swlat: params.swlat, - swlng: params.swlng - } - } - } ); + const boundsFilterForUser = await ObservationQueryBuilder.boundsFilterForUser( req, params ); + if ( !_.isEmpty( boundsFilterForUser ) ) { + searchFilters.push( boundsFilterForUser ); + } } if ( params.lat && params.lng ) { @@ -951,15 +1002,9 @@ ObservationQueryBuilder.reqToElasticQueryComponents = async req => { // Set the place filters, which gets REALLY complicated when trying to decide // when to search on private places or not if ( params.place_id && params.place_id !== "any" ) { - // This is the basic filter of places everyone should see - const publicPlaceFilter = esClient.termFilter( "place_ids.keyword", params.place_id ); - if ( req.userSession ) { - const placeFilterForUser = await ObservationQueryBuilder.placeFilterForUser( req, params ); - if ( !_.isEmpty( placeFilterForUser ) ) { - searchFilters.push( placeFilterForUser ); - } - } else { - searchFilters.push( publicPlaceFilter ); + const placeFilterForUser = await ObservationQueryBuilder.placeFilterForUser( req, params ); + if ( !_.isEmpty( placeFilterForUser ) ) { + searchFilters.push( placeFilterForUser ); } } diff --git a/schema/fixtures.js b/schema/fixtures.js index f33dd559..1ccdd2b9 100644 --- a/schema/fixtures.js +++ b/schema/fixtures.js @@ -962,7 +962,7 @@ "location": "42.1,-71.5", "private_location": "42.1,-71.5", "geojson": { "type": "Point", "coordinates": [ -71.5, 42.1 ] }, - "private_geojson": { "type": "Point", "coordinates": [ -71.5, 42.1 ] }, + "private_geojson": { "type": "Point", "coordinates": [ -71.6, 42.2 ] }, "geoprivacy": "obscured" }, { diff --git a/test/controllers/v1/observations_controller.js b/test/controllers/v1/observations_controller.js index 0168f47b..6180c095 100644 --- a/test/controllers/v1/observations_controller.js +++ b/test/controllers/v1/observations_controller.js @@ -2,6 +2,7 @@ const { expect } = require( "chai" ); const moment = require( "moment" ); const _ = require( "lodash" ); const { observations } = require( "inaturalistjs" ); +const esClient = require( "../../../lib/es_client" ); const testHelper = require( "../../../lib/test_helper" ); const Observation = require( "../../../lib/models/observation" ); const Project = require( "../../../lib/models/project" ); @@ -450,21 +451,21 @@ describe( "ObservationsController", ( ) => { it( "filters by bounding box", async ( ) => { const q = await Q( { - nelat: 1, - nelng: 2, - swlat: 3, - swlng: 4 + nelat: 3, + nelng: 4, + swlat: 1, + swlng: 2 } ); - expect( q.filters ).to.eql( [{ + expect( q.filters ).to.eql( [esClient.envelopeFilter( { envelope: { geojson: { - nelat: 1, - nelng: 2, - swlat: 3, - swlng: 4 + nelat: 3, + nelng: 4, + swlat: 1, + swlng: 2 } } - }] ); + } )] ); } ); it( "filters by point and radius", async ( ) => { diff --git a/test/integration/v1/observations.js b/test/integration/v1/observations.js index 9a54da9e..065d274f 100644 --- a/test/integration/v1/observations.js +++ b/test/integration/v1/observations.js @@ -858,6 +858,43 @@ describe( "Observations", ( ) => { } ) .expect( 200, done ); } ); + + describe( "filtering by bounds", ( ) => { + // an observation with public and private coords, where the coords are different + const obsWithPublicAndPrivateCoords = _.find( + fixtures.elasticsearch.observations.observation, + o => o.id === 24 + ); + const privateBounds = { + swlat: obsWithPublicAndPrivateCoords.private_geojson.coordinates[1] - 0.00001, + swlng: obsWithPublicAndPrivateCoords.private_geojson.coordinates[0] - 0.00001, + nelat: obsWithPublicAndPrivateCoords.private_geojson.coordinates[1] + 0.00001, + nelng: obsWithPublicAndPrivateCoords.private_geojson.coordinates[0] + 0.00001 + }; + const privateBoundsParams = _.map( privateBounds, ( v, k ) => `${k}=${v}` ).join( "&" ); + + it( "does not use private coordinates for un authenticated user with bounding boxes", function ( done ) { + request( this.app ).get( `/v1/observations?${privateBoundsParams}` ) + .expect( res => { + expect( _.map( res.body.results, "id" ) ).to.not.include( obsWithPublicAndPrivateCoords.id ); + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + + it( "uses private coordinates when authenticated users search their own obs with bounding boxes", function ( done ) { + const observerToken = jwt.sign( { user_id: obsWithPublicAndPrivateCoords.user.id }, + config.jwtSecret || "secret", + { algorithm: "HS512" } ); + request( this.app ).get( `/v1/observations?${privateBoundsParams}` ).set( "Authorization", observerToken ) + .expect( res => { + expect( _.map( res.body.results, "id" ) ).to.include( obsWithPublicAndPrivateCoords.id ); + } ) + .expect( "Content-Type", /json/ ) + .expect( 200, done ); + } ); + } ); + describe( "filtering by a collection project they curate", ( ) => { const project = _.find( fixtures.elasticsearch.projects.project, p => p.id === 2005 ); const placeId = _.find( project.search_parameters, sp => sp.field === "place_id" ).value; diff --git a/test/models/observation_query_builder.js b/test/models/observation_query_builder.js new file mode 100644 index 00000000..71754709 --- /dev/null +++ b/test/models/observation_query_builder.js @@ -0,0 +1,568 @@ +const chai = require( "chai" ); +const chaiAsPromised = require( "chai-as-promised" ); +const sinon = require( "sinon" ); +const ObservationQueryBuilder = require( "../../lib/models/observation_query_builder" ); +const ProjectUser = require( "../../lib/models/project_user" ); +const UserSession = require( "../../lib/user_session" ); + +const { expect } = chai; +chai.use( chaiAsPromised ); + +describe( "ObservationQueryBuilder", ( ) => { + const sinonSandbox = sinon.createSandbox( ); + + afterEach( ( ) => { + sinonSandbox.restore( ); + } ); + + describe( "contextTrustingUsers", ( ) => { + it( "no trusting users if there is no logged-in user", async ( ) => { + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( { } ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in users trust themselves if there is no collection or umbrella", async ( ) => { + const loggedInReq = { + userSession: { + user_id: 12345 + } + }; + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReq ); + expect( usersTrustingForAny ).to.eql( [12345] ); + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in users does not trust themselves in an untrusted collection project context", async ( ) => { + const loggedInReqWithCollection = { + userSession: { + user_id: 12345 + }, + _collectionProject: { + id: 9999 + } + }; + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithCollection ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in user does not trust themself in an untrusted umbrella project context", async ( ) => { + const loggedInReqWithUmbrella = { + userSession: { + user_id: 12345 + }, + _umbrellaProject: { + id: 9999 + } + }; + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithUmbrella ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in collection project curators trusted by members trusting any", async ( ) => { + const loggedInReqWithCollection = { + userSession: new UserSession( { + user_id: 12345, + curatedProjectsIDs: [9999] + } ), + _collectionProject: { + id: 9999, + prefers_user_trust: true + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [1111] ) + .withArgs( 9999, "taxon" ) + .returns( [] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithCollection ); + expect( usersTrustingForAny ).to.eql( [1111] ); + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in collection project curators trusted by members trusting on taxon", async ( ) => { + const loggedInReqWithCollection = { + userSession: new UserSession( { + user_id: 12345, + curatedProjectsIDs: [9999] + } ), + _collectionProject: { + id: 9999, + prefers_user_trust: true + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [] ) + .withArgs( 9999, "taxon" ) + .returns( [1111] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithCollection ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.eql( [1111] ); + } ); + + it( "logged-in collection project curators not trusted by anyone if project does not enable trust", async ( ) => { + const loggedInReqWithCollection = { + userSession: new UserSession( { + user_id: 12345, + curatedProjectsIDs: [9999] + } ), + _collectionProject: { + id: 9999, + prefers_user_trust: false + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [1111] ) + .withArgs( 9999, "taxon" ) + .returns( [2222] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithCollection ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in umbrella project curators trusted by members trusting any", async ( ) => { + const loggedInReqWithUmbrella = { + userSession: new UserSession( { + user_id: 12345, + curatedProjectsIDs: [9999] + } ), + _umbrellaProject: { + id: 9999, + prefers_user_trust: true + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [1111] ) + .withArgs( 9999, "taxon" ) + .returns( [] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithUmbrella ); + expect( usersTrustingForAny ).to.eql( [1111] ); + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in umbrella project curators trusted by members trusting on taxon", async ( ) => { + const loggedInReqWithUmbrella = { + userSession: new UserSession( { + user_id: 12345, + curatedProjectsIDs: [9999] + } ), + _umbrellaProject: { + id: 9999, + prefers_user_trust: true + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [] ) + .withArgs( 9999, "taxon" ) + .returns( [1111] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithUmbrella ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.eql( [1111] ); + } ); + + it( "logged-in umbrella project curators not trusted by anyone if project does not enable trust", async ( ) => { + const loggedInReqWithUmbrella = { + userSession: new UserSession( { + user_id: 12345, + curatedProjectsIDs: [9999] + } ), + _umbrellaProject: { + id: 9999, + prefers_user_trust: false + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [1111] ) + .withArgs( 9999, "taxon" ) + .returns( [2222] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithUmbrella ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in users trust themselves if they trust the collection project with any", async ( ) => { + const loggedInReqWithCollection = { + userSession: new UserSession( { + user_id: 12345 + } ), + _collectionProject: { + id: 9999, + prefers_user_trust: true + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [12345, 1111] ) + .withArgs( 9999, "taxon" ) + .returns( [2222] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithCollection ); + expect( usersTrustingForAny ).to.eql( [12345] ); + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in users trust themselves if they trust the collection project with taxa", async ( ) => { + const loggedInReqWithCollection = { + userSession: new UserSession( { + user_id: 12345 + } ), + _collectionProject: { + id: 9999, + prefers_user_trust: true + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [1111] ) + .withArgs( 9999, "taxon" ) + .returns( [12345, 2222] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithCollection ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.eql( [12345] ); + } ); + + it( "logged-in users get no trust if the collection project has not enabled trusting", async ( ) => { + const loggedInReqWithCollection = { + userSession: new UserSession( { + user_id: 12345 + } ), + _collectionProject: { + id: 9999, + prefers_user_trust: false + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [1111] ) + .withArgs( 9999, "taxon" ) + .returns( [12345, 2222] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithCollection ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in users trust themselves if they trust the umbrella project with any", async ( ) => { + const loggedInReqWithUmbrella = { + userSession: new UserSession( { + user_id: 12345 + } ), + _umbrellaProject: { + id: 9999, + prefers_user_trust: true + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [12345, 1111] ) + .withArgs( 9999, "taxon" ) + .returns( [2222] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithUmbrella ); + expect( usersTrustingForAny ).to.eql( [12345] ); + expect( usersTrustingForTaxon ).to.be.empty; + } ); + + it( "logged-in users trust themselves if they trust the umbrella project with taxa", async ( ) => { + const loggedInReqWithUmbrella = { + userSession: new UserSession( { + user_id: 12345 + } ), + _umbrellaProject: { + id: 9999, + prefers_user_trust: true + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [1111] ) + .withArgs( 9999, "taxon" ) + .returns( [12345, 2222] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithUmbrella ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.eql( [12345] ); + } ); + + it( "logged-in users get no trust if the umbrella project has not enabled trusting", async ( ) => { + const loggedInReqWithUmbrella = { + userSession: new UserSession( { + user_id: 12345 + } ), + _umbrellaProject: { + id: 9999, + prefers_user_trust: false + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [1111] ) + .withArgs( 9999, "taxon" ) + .returns( [12345, 2222] ); + const { + usersTrustingForAny, + usersTrustingForTaxon + } = await ObservationQueryBuilder.contextTrustingUsers( loggedInReqWithUmbrella ); + expect( usersTrustingForAny ).to.be.empty; + expect( usersTrustingForTaxon ).to.be.empty; + } ); + } ); + + describe( "placeFilterForUser", ( ) => { + const placeID = 9999; + const expectedPublicFilter = { + terms: { + "place_ids.keyword": [placeID] + } + }; + const expectedPrivateFilter = userIDs => ( { + bool: { + must: [{ + terms: { + "private_place_ids.keyword": [placeID] + } + }, { + terms: { + "user.id.keyword": userIDs + } + }] + } + } ); + const expectedPrivateFilterTaxa = userIDs => { + const privateFilter = expectedPrivateFilter( userIDs ); + privateFilter.bool.must.splice( 1, 0, { + terms: { + taxon_geoprivacy: [ + "obscured", + "private" + ] + } + } ); + privateFilter.bool.must_not = [{ + exists: { + field: "geoprivacy" + } + }]; + return privateFilter; + }; + + it( "no filter if there is no place_id param", async ( ) => { + const placeFilter = await ObservationQueryBuilder.placeFilterForUser( { }, { } ); + expect( placeFilter ).to.be.null; + } ); + + it( "uses only public filters if there is no logged-in user", async ( ) => { + const placeFilter = await ObservationQueryBuilder.placeFilterForUser( + { }, { place_id: placeID } + ); + expect( placeFilter ).to.eql( expectedPublicFilter ); + } ); + + it( "uses a private filter component if there is a logged-in user", async ( ) => { + const loggedInReq = { + userSession: { + user_id: 12345 + } + }; + const placeFilter = await ObservationQueryBuilder.placeFilterForUser( + loggedInReq, { place_id: placeID } + ); + expect( placeFilter ).to.eql( { + bool: { + should: [ + expectedPrivateFilter( [12345] ), + expectedPublicFilter + ] + } + } ); + } ); + + it( "uses a private filter component for curators of trusted collection projects", async ( ) => { + const loggedInReqWithCollection = { + userSession: new UserSession( { + user_id: 12345, + curatedProjectsIDs: [9999] + } ), + _collectionProject: { + id: 9999, + prefers_user_trust: true + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [1111, 2222] ) + .withArgs( 9999, "taxon" ) + .returns( [3333, 4444] ); + const placeFilter = await ObservationQueryBuilder.placeFilterForUser( + loggedInReqWithCollection, { place_id: placeID } + ); + expect( placeFilter ).to.eql( { + bool: { + should: [ + expectedPrivateFilter( [1111, 2222] ), + expectedPrivateFilterTaxa( [3333, 4444] ), + expectedPublicFilter + ] + } + } ); + } ); + } ); + + describe( "boundsFilterForUser", ( ) => { + const boundsParams = { + swlat: 1, swlng: 2, nelat: 3, nelng: 4 + }; + const expectedPublicFilter = { + geo_shape: { + geojson: { + shape: { + type: "envelope", + coordinates: [[2, 3], [4, 1]] + } + } + } + }; + const expectedPrivateFilter = userIDs => ( { + bool: { + must: [{ + geo_shape: { + private_geojson: { + shape: { + type: "envelope", + coordinates: [[2, 3], [4, 1]] + } + } + } + }, { + terms: { + "user.id.keyword": userIDs + } + }] + } + } ); + const expectedPrivateFilterTaxa = userIDs => { + const privateFilter = expectedPrivateFilter( userIDs ); + privateFilter.bool.must.splice( 1, 0, { + terms: { + taxon_geoprivacy: [ + "obscured", + "private" + ] + } + } ); + privateFilter.bool.must_not = [{ + exists: { + field: "geoprivacy" + } + }]; + return privateFilter; + }; + + it( "no filter if there are no bounds params", async ( ) => { + const boundsFilter = await ObservationQueryBuilder.boundsFilterForUser( { }, { } ); + expect( boundsFilter ).to.be.null; + } ); + + it( "uses only public filters if there is no logged-in user", async ( ) => { + const boundsFilter = await ObservationQueryBuilder.boundsFilterForUser( { }, boundsParams ); + expect( boundsFilter ).to.eql( expectedPublicFilter ); + } ); + + it( "uses a private filter component if there is a logged-in user", async ( ) => { + const loggedInReq = { + userSession: { + user_id: 12345 + } + }; + const boundsFilter = await ObservationQueryBuilder.boundsFilterForUser( + loggedInReq, boundsParams + ); + expect( boundsFilter ).to.eql( { + bool: { + should: [ + expectedPrivateFilter( [12345] ), + expectedPublicFilter + ] + } + } ); + } ); + + it( "uses a private filter component for curators of trusted collection projects", async ( ) => { + const loggedInReqWithCollection = { + userSession: new UserSession( { + user_id: 12345, + curatedProjectsIDs: [9999] + } ), + _collectionProject: { + id: 9999, + prefers_user_trust: true + } + }; + sinonSandbox.stub( ProjectUser, "usersTrustingProjectFor" ) + .withArgs( 9999, "any" ) + .returns( [1111, 2222] ) + .withArgs( 9999, "taxon" ) + .returns( [3333, 4444] ); + const boundsFilter = await ObservationQueryBuilder.boundsFilterForUser( + loggedInReqWithCollection, boundsParams + ); + expect( boundsFilter ).to.eql( { + bool: { + should: [ + expectedPrivateFilter( [1111, 2222] ), + expectedPrivateFilterTaxa( [3333, 4444] ), + expectedPublicFilter + ] + } + } ); + } ); + } ); +} );