diff --git a/packages/server/modules/activitystream/graph/resolvers/activity.js b/packages/server/modules/activitystream/graph/resolvers/activity.js index d75d807ff..2c9338ace 100644 --- a/packages/server/modules/activitystream/graph/resolvers/activity.js +++ b/packages/server/modules/activitystream/graph/resolvers/activity.js @@ -1,7 +1,4 @@ -const appRoot = require( 'app-root-path' ) -const { validateServerRole, validateScopes } = require( `${appRoot}/modules/shared` ) -const { ForbiddenError, UserInputError, ApolloError, withFilter } = require( 'apollo-server-express' ) -const { getUserActivity, getStreamActivity, getResourceActivity, getUserTimeline, getActivityCountByResourceId, getActivityCountByStreamId, getActivityCountByUserId } = require( '../../services/index' ) +const { getUserActivity, getStreamActivity, getResourceActivity, getUserTimeline, getActivityCountByResourceId, getActivityCountByStreamId, getActivityCountByUserId, getTimelineCount } = require( '../../services/index' ) module.exports = { @@ -11,6 +8,13 @@ module.exports = { let { items, cursor } = await getUserActivity( { userId: parent.id, actionType: args.actionType, after: args.after, before: args.before, limit: args.limit } ) let totalCount = await getActivityCountByUserId( { userId: parent.id } ) + return { items, cursor, totalCount } + }, + + async timeline( parent, args, context, info ) { + let { items, cursor } = await getUserTimeline( { userId: parent.id, after: args.after, before: args.before, limit: args.limit } ) + let totalCount = await getTimelineCount( { userId: parent.id } ) + return { items, cursor, totalCount } } }, diff --git a/packages/server/modules/activitystream/graph/schemas/activity.graphql b/packages/server/modules/activitystream/graph/schemas/activity.graphql index 75be57be4..2cec95766 100644 --- a/packages/server/modules/activitystream/graph/schemas/activity.graphql +++ b/packages/server/modules/activitystream/graph/schemas/activity.graphql @@ -7,6 +7,9 @@ extend type User { activity(actionType: String, after: DateTime, before: DateTime, limit: Int! = 25): ActivityCollection @hasRole(role: "server:user") @hasScope(scope: "users:read") + timeline(after: DateTime, before: DateTime, limit: Int! = 25): ActivityCollection + @hasRole(role: "server:user") + @hasScopes(scopes: ["users:read", "streams:read"]) } extend type Stream { diff --git a/packages/server/modules/activitystream/services/index.js b/packages/server/modules/activitystream/services/index.js index 33d89a3c8..c02ac4a94 100644 --- a/packages/server/modules/activitystream/services/index.js +++ b/packages/server/modules/activitystream/services/index.js @@ -3,7 +3,8 @@ const appRoot = require( 'app-root-path' ) const knex = require( `${appRoot}/db/knex` ) -const StreamActivity = ( ) => knex( 'stream_activity' ) +const StreamActivity = () => knex( 'stream_activity' ) +const StreamAcl = ( ) => knex( 'stream_acl' ) module.exports = { @@ -72,7 +73,7 @@ module.exports = { } if ( !before ) { - before = Date.now() + before = new Date() } let dbRawQuery = ` @@ -84,7 +85,7 @@ module.exports = { LIMIT ? ` - let results = await knex.raw( dbRawQuery, [ userId, before, limit ] ) + let results = ( await knex.raw( dbRawQuery, [ userId, before, limit ] ) ).rows return { items: results, cursor: results.length > 0 ? results[ results.length - 1 ].time.toISOString() : null } }, @@ -101,5 +102,13 @@ module.exports = { async getActivityCountByUserId( { userId } ) { let [ res ] = await StreamActivity().count().where( { userId } ) return parseInt( res.count ) + }, + + async getTimelineCount( { userId } ) { + let [ res ] = await StreamAcl().count() + .innerJoin( 'stream_activity', { 'stream_acl.resourceId': 'stream_activity.streamId' } ) + .where( { 'stream_acl.userId': userId } ) + + return parseInt( res.count ) } } diff --git a/packages/server/modules/activitystream/tests/activity.spec.js b/packages/server/modules/activitystream/tests/activity.spec.js index 0c94efc89..f1b6a1340 100644 --- a/packages/server/modules/activitystream/tests/activity.spec.js +++ b/packages/server/modules/activitystream/tests/activity.spec.js @@ -1,24 +1,27 @@ /* istanbul ignore file */ const chai = require( 'chai' ) const chaiHttp = require( 'chai-http' ) -const assert = require( 'assert' ) const appRoot = require( 'app-root-path' ) const { createUser } = require( '../../core/services/users' ) const { createPersonalAccessToken } = require( '../../core/services/tokens' ) -const { createStream, grantPermissionsStream } = require( '../../core/services/streams' ) const { createObject } = require( '../../core/services/objects' ) -const { init } = require( `${appRoot}/app` ) +const { getUserActivity } = require( '../services' ) +const { init, startHttp } = require( `${appRoot}/app` ) const knex = require( `${appRoot}/db/knex` ) const expect = chai.expect chai.use( chaiHttp ) +const serverAddress = `http://localhost:${process.env.PORT || 3000}` + describe( 'Activity @activity', () => { + let testServer + let userIz = { name: 'Izzy Lyseggen', - email: 'izzyzzi@speckle.systems', + email: 'izzybizzi@speckle.systems', password: 'sp0ckle sucks 9001' } @@ -40,6 +43,8 @@ describe( 'Activity @activity', () => { isPublic: true } + let branchPublic = { name: '🍁maple branch' } + let streamSecret = { name: 'a secret stream for me', description: 'for no one to see!', @@ -61,7 +66,10 @@ describe( 'Activity @activity', () => { await knex.migrate.rollback( ) await knex.migrate.latest() - await init() + let { app } = await init( ) + let { server } = await startHttp( app ) + + testServer = server // create users and tokens userIz.id = await createUser( userIz ) @@ -72,37 +80,129 @@ describe( 'Activity @activity', () => { userCr.token = `Bearer ${( await createPersonalAccessToken( userCr.id, 'cristi test token', [ 'streams:read', 'streams:write', 'users:read', 'users:email', 'tokens:write', 'tokens:read', 'profile:read', 'profile:email' ] ) )}` userX.id = await createUser( userX ) - userX.token = `Bearer ${( await createPersonalAccessToken( userX.id, 'no users test token', [ 'streams:read', 'streams:write' ] ) )}` - - // create some activity - streamPublic.id = await createStream( { ...streamPublic, ownerId: userIz.id } ) - await grantPermissionsStream( { streamId: streamPublic.id, userId: userCr.id, role: 'stream:contributor' } ) - streamSecret.id = await createStream( { ...streamSecret, ownerId: userCr.id } ) - testObj.id = await createObject( streamPublic.id, testObj ) - testObj2.id = await createObject( streamSecret.id, testObj2 ) + userX.token = `Bearer ${( await createPersonalAccessToken( userX.id, 'no users:read test token', [ 'streams:read', 'streams:write' ] ) )}` } ) after( async ( ) => { - await knex.migrate.rollback( ) + await knex.migrate.rollback() + testServer.close( ) } ) it( 'Should get a user\'s activity', async () => { + let { items: activity } = await getUserActivity( { userId: userCr.id } ) + expect( activity.length ).to.equal( 1 ) + expect( activity[ 0 ] ).to.have.property( 'actionType' ) + expect( activity[ 0 ].actionType ).to.equal( 'user_create' ) + } ) + + it( 'Should create activity', async () => { + // create stream (cr1) + const resStream1 = await sendRequest( userCr.token, { query: 'mutation createStream($myStream:StreamCreateInput!) { streamCreate(stream: $myStream) }', variables: { myStream: streamSecret } } ) + expect( noErrors( resStream1 ) ) + streamSecret.id = resStream1.body.data.streamCreate + + // create commit (cr2) + testObj2.id = await createObject( streamSecret.id, testObj2 ) + const resCommit1 = await sendRequest( userCr.token, { query: `mutation { commitCreate(commit: {streamId: "${streamSecret.id}", branchName: "main", objectId: "${testObj2.id}", message: "first commit"})}` } ) + expect( noErrors( resCommit1 ) ) + + // create stream #2 (iz1) + const resStream2 = await sendRequest( userIz.token, { query: 'mutation createStream($myStream:StreamCreateInput!) { streamCreate(stream: $myStream) }', variables: { myStream: streamPublic } } ) + expect( noErrors( resStream2 ) ) + streamPublic.id = resStream2.body.data.streamCreate + + // create branch (iz2) + const resBranch = await sendRequest( userIz.token, { query: `mutation { branchCreate(branch: { streamId: "${streamPublic.id}", name: "${branchPublic.name}" }) }` } ) + expect( noErrors( resBranch ) ) + branchPublic.id = resBranch.body.data.branchCreate + + // create commit #2 (iz3) + testObj.id = await createObject( streamPublic.id, testObj ) + const resCommit2 = await sendRequest( userIz.token, { query: `mutation { commitCreate(commit: { streamId: "${streamPublic.id}", branchName: "${branchPublic.name}", objectId: "${testObj.id}", message: "first commit" })}` } ) + expect( noErrors( resCommit2 ) ) + + // add collaborator (iz4) + const resCollab = await sendRequest( userIz.token, { query: `mutation { streamGrantPermission( permissionParams: { streamId: "${streamPublic.id}", userId: "${userCr.id}", role: "stream:contributor" } ) }` } ) + expect( noErrors( resCollab ) ) + + let { items: activityC } = await getUserActivity( { userId: userCr.id } ) + expect( activityC.length ).to.equal( 3 ) + expect( activityC[ 0 ].actionType ).to.equal( 'commit_create' ) + + let { items: activityI } = await getUserActivity( { userId: userIz.id } ) + expect( activityI.length ).to.equal( 5 ) + expect( activityI[ 0 ].actionType ).to.equal( 'stream_permissions_add' ) + } ) + + it( 'Should get a user\'s timeline', async () => { + const res = await sendRequest( userIz.token, { query: `query {user(id:"${userCr.id}") { name timeline { totalCount items {streamId resourceType resourceId actionType userId message time}}} }` } ) + expect( noErrors( res ) ) + expect( res.body.data.user.timeline.items.length ).to.equal( 6 ) // sum of all actions in 'should create activity' + expect( res.body.data.user.timeline.totalCount ).to.equal( 6 ) + } ) + + it( 'Should get another user\'s activity', async () => { + const res = await sendRequest( userIz.token, { query: `query {user(id:"${userCr.id}") { name activity { totalCount items {streamId resourceType resourceId actionType userId message time}}} }` } ) + expect( noErrors( res ) ) + expect( res.body.data.user.activity.items.length ).to.equal( 3 ) + expect( res.body.data.user.activity.totalCount ).to.equal( 3 ) } ) it( 'Should get a stream\'s activity', async () => { - + const res = await sendRequest( userCr.token, { query: `query { stream(id: "${streamPublic.id}") { activity { totalCount items {streamId resourceId actionType message} } } }` } ) + expect( noErrors( res ) ) + let activity = res.body.data.stream.activity + expect( activity.items.length ).to.equal( 4 ) + expect( activity.totalCount ).to.equal( 4 ) + expect( activity.items[ activity.totalCount - 1 ].actionType ).to.equal( 'stream_create' ) } ) it( 'Should get a branch\'s activity', async () => { - + const res = await sendRequest( userCr.token, { query: `query { stream(id: "${streamPublic.id}") { branch(name: "${branchPublic.name}") { activity { totalCount items {streamId resourceId actionType message} } } } }` } ) + expect( noErrors( res ) ) + let activity = res.body.data.stream.branch.activity + expect( activity.items.length ).to.equal( 1 ) + expect( activity.totalCount ).to.equal( 1 ) + expect( activity.items[ 0 ].actionType ).to.equal( 'branch_create' ) } ) it( 'Should *not* get a stream\'s activity if you don\'t have access to it', async () => { + const res = await sendRequest( userIz.token, { query: `query {stream(id:"${streamSecret.id}") {name activity {items {streamId resourceType resourceId actionType userId message time}}} }` } ) + expect( res.body.errors.length ).to.equal( 1 ) + } ) + it( 'Should *not* get a stream\'s activity if you are not a server user', async () => { + const res = await sendRequest( null, { query: `query {stream(id:"${streamPublic.id}") {name activity {items {streamId resourceType resourceId actionType userId message time}}} }` } ) + expect( res.body.errors.length ).to.equal( 1 ) } ) it( 'Should *not* get a user\'s activity without the `users:read` scope', async () => { + const res = await sendRequest( userX.token, { query: `query {user(id:"${userCr.id}") { name activity {items {streamId resourceType resourceId actionType userId message time}}} }` } ) + expect( res.body.errors.length ).to.equal( 1 ) + } ) + it( 'Should *not* get a user\'s timeline without the `users:read` scope', async () => { + const res = await sendRequest( userX.token, { query: `query {user(id:"${userCr.id}") { name timeline {items {streamId resourceType resourceId actionType userId message time}}} }` } ) + expect( res.body.errors.length ).to.equal( 1 ) } ) } ) + +/** + * Sends a graphql request. Convenience wrapper. + * @param {string} auth the user's token + * @param {string} obj the query/mutation to send + * @return {Promise} the awaitable request + */ +function sendRequest( auth, obj, address = serverAddress ) { + return chai.request( address ).post( '/graphql' ).set( 'Authorization', auth ).send( obj ) +} + +/** + * Checks the response body for errors. To be used in expect assertions. + * Will throw an error if 'errors' exist. + * @param {*} res + */ +function noErrors( res ) { + if ( 'errors' in res.body ) throw new Error( `Failed GraphQL request: ${res.body.errors[ 0 ].message}` ) +} diff --git a/packages/server/modules/core/graph/directives/hasScope.js b/packages/server/modules/core/graph/directives/hasScope.js index 965e318e5..85b3c5fb1 100644 --- a/packages/server/modules/core/graph/directives/hasScope.js +++ b/packages/server/modules/core/graph/directives/hasScope.js @@ -13,6 +13,22 @@ module.exports = { const currentScopes = context.scopes await validateScopes( currentScopes, requiredScope ) + const data = await resolver.call( this, parent, args, context, info ) + return data + } + } + }, + hasScopes: class HasScopeDirective extends SchemaDirectiveVisitor { + visitFieldDefinition( field, details ) { + const { resolver = field.resolve || defaultFieldResolver, name } = field + const requiredScopes = this.args.scopes + + field.resolve = async function ( parent, args, context, info ) { + const currentScopes = context.scopes + requiredScopes.forEach( async ( requiredScope ) => { + await validateScopes( currentScopes, requiredScope ) + } ) + const data = await resolver.call( this, parent, args, context, info ) return data } diff --git a/packages/server/modules/index.js b/packages/server/modules/index.js index 741d6b7ad..938474c7a 100644 --- a/packages/server/modules/index.js +++ b/packages/server/modules/index.js @@ -14,12 +14,12 @@ exports.init = async ( app ) => { let moduleDirs = [ './core', './auth', './apiexplorer', './emails', './pwdreset', './serverinvites', './previews' ] // TODO: add './invites' // Stage 1: initialise all modules - for ( let dir of moduleDirs ){ + for ( let dir of moduleDirs ) { await require( dir ).init( app ) } // Stage 2: finalize init all modules - for ( let dir of moduleDirs ){ + for ( let dir of moduleDirs ) { await require( dir ).finalize( app ) } } @@ -30,6 +30,7 @@ exports.graph = ( ) => { let typeDefs = [ ` ${scalarSchemas} directive @hasScope(scope: String!) on FIELD_DEFINITION + directive @hasScopes(scopes: [String]!) on FIELD_DEFINITION directive @hasRole(role: String!) on FIELD_DEFINITION type Query {