Merge pull request #3075 from Human-Connection/3074-don’t-expose-all-properties-of-report

feat: 🍰 Expose sensitive report type to moderators only
This commit is contained in:
Robert Schäfer 2020-02-21 12:43:28 +01:00 committed by GitHub
commit e164104791
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 259 additions and 200 deletions

View File

@ -152,6 +152,7 @@ export default shield(
User: {
email: or(isMyOwn, isAdmin),
},
Report: isModerator,
},
{
debug,

View File

@ -9,14 +9,6 @@ const driver = getDriver()
let query, authenticatedUser, owner, anotherRegularUser, administrator, variables, moderator
const userQuery = gql`
query($name: String) {
User(name: $name) {
email
}
}
`
describe('authorization', () => {
beforeAll(async () => {
await cleanDatabase()
@ -30,7 +22,11 @@ describe('authorization', () => {
query = createTestClient(server).query
})
describe('given two existing users', () => {
afterEach(async () => {
await cleanDatabase()
})
describe('given an owner, an other user, an admin, a moderator', () => {
beforeEach(async () => {
;[owner, anotherRegularUser, administrator, moderator] = await Promise.all([
Factory.build(
@ -79,15 +75,20 @@ describe('authorization', () => {
variables = {}
})
afterEach(async () => {
await cleanDatabase()
})
describe('access email address', () => {
const userQuery = gql`
query($name: String) {
User(name: $name) {
email
}
}
`
describe('unauthenticated', () => {
beforeEach(() => {
authenticatedUser = null
})
it("throws an error and does not expose the owner's email address", async () => {
await expect(
query({ query: userQuery, variables: { name: 'Owner' } }),
@ -143,7 +144,7 @@ describe('authorization', () => {
})
})
describe('administrator', () => {
describe('as an administrator', () => {
beforeEach(async () => {
authenticatedUser = await administrator.toJson()
})

View File

@ -58,7 +58,7 @@ const reportMutation = gql`
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
id
reportId
}
}
`

View File

@ -1,20 +1,10 @@
const transformReturnType = record => {
return {
...record.get('review').properties,
report: record.get('report').properties,
resource: {
__typename: record.get('type'),
...record.get('resource').properties,
},
}
}
import log from './helpers/databaseLogger'
export default {
Mutation: {
review: async (_object, params, context, _resolveInfo) => {
const { user: moderator, driver } = context
let createdRelationshipWithNestedAttributes = null // return value
const session = driver.session()
try {
const cypher = `
@ -25,10 +15,11 @@ export default {
ON CREATE SET review.createdAt = $dateTime, review.updatedAt = review.createdAt
ON MATCH SET review.updatedAt = $dateTime
SET review.disable = $params.disable
SET report.updatedAt = $dateTime, report.closed = $params.closed
SET resource.disabled = review.disable
SET report.updatedAt = $dateTime, report.disable = review.disable, report.closed = $params.closed
SET resource.disabled = report.disable
RETURN review, report, resource, labels(resource)[0] AS type
WITH review, report, resource {.*, __typename: labels(resource)[0]} AS finalResource
RETURN review {.*, report: properties(report), resource: properties(finalResource)}
`
const reviewWriteTxResultPromise = session.writeTransaction(async txc => {
const reviewTransactionResponse = await txc.run(cypher, {
@ -36,16 +27,14 @@ export default {
moderatorId: moderator.id,
dateTime: new Date().toISOString(),
})
return reviewTransactionResponse.records.map(transformReturnType)
log(reviewTransactionResponse)
return reviewTransactionResponse.records.map(record => record.get('review'))
})
const txResult = await reviewWriteTxResultPromise
if (!txResult[0]) return null
createdRelationshipWithNestedAttributes = txResult[0]
const [reviewed] = await reviewWriteTxResultPromise
return reviewed || null
} finally {
session.close()
}
return createdRelationshipWithNestedAttributes
},
},
}

View File

@ -1,23 +1,13 @@
import log from './helpers/databaseLogger'
const transformReturnType = record => {
return {
...record.get('report').properties,
resource: {
__typename: record.get('type'),
...record.get('resource').properties,
},
}
}
export default {
Mutation: {
fileReport: async (_parent, params, context, _resolveInfo) => {
const { resourceId, reasonCategory, reasonDescription } = params
const { driver, user } = context
const session = driver.session()
const reportWriteTxResultPromise = session.writeTransaction(async transaction => {
const reportTransactionResponse = await transaction.run(
const fileReportWriteTxResultPromise = session.writeTransaction(async transaction => {
const fileReportTransactionResponse = await transaction.run(
`
MATCH (submitter:User {id: $submitterId})
MATCH (resource {id: $resourceId})
@ -27,7 +17,8 @@ export default {
WITH submitter, resource, report
CREATE (report)<-[filed:FILED {createdAt: $createdAt, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription}]-(submitter)
RETURN report, resource, labels(resource)[0] AS type
WITH filed, report, resource {.*, __typename: labels(resource)[0]} AS finalResource
RETURN filed {.*, reportId: report.id, resource: properties(finalResource)} AS filedReport
`,
{
resourceId,
@ -37,13 +28,12 @@ export default {
reasonDescription,
},
)
log(reportTransactionResponse)
return reportTransactionResponse.records.map(transformReturnType)
log(fileReportTransactionResponse)
return fileReportTransactionResponse.records.map(record => record.get('filedReport'))
})
try {
const [createdRelationshipWithNestedAttributes] = await reportWriteTxResultPromise
if (!createdRelationshipWithNestedAttributes) return null
return createdRelationshipWithNestedAttributes
const [filedReport] = await fileReportWriteTxResultPromise
return filedReport || null
} finally {
session.close()
}
@ -76,14 +66,24 @@ export default {
filterClause = ''
}
if (params.closed) filterClause = 'AND report.closed = true'
switch (params.closed) {
case true:
filterClause = 'AND report.closed = true'
break
case false:
filterClause = 'AND report.closed = false'
break
default:
break
}
const offset =
params.offset && typeof params.offset === 'number' ? `SKIP ${params.offset}` : ''
const limit = params.first && typeof params.first === 'number' ? `LIMIT ${params.first}` : ''
const reportReadTxPromise = session.readTransaction(async transaction => {
const allReportsTransactionResponse = await transaction.run(
const reportsReadTxPromise = session.readTransaction(async transaction => {
const reportsTransactionResponse = await transaction.run(
// !!! this Cypher query returns multiple reports on the same resource! i will create an issue for refactoring (bug fixing)
`
MATCH (report:Report)-[:BELONGS_TO]->(resource)
WHERE (resource:User OR resource:Post OR resource:Comment)
@ -101,11 +101,11 @@ export default {
${offset} ${limit}
`,
)
log(allReportsTransactionResponse)
return allReportsTransactionResponse.records.map(record => record.get('report'))
log(reportsTransactionResponse)
return reportsTransactionResponse.records.map(record => record.get('report'))
})
try {
const reports = await reportReadTxPromise
const reports = await reportsReadTxPromise
return reports
} finally {
session.close()

View File

@ -10,18 +10,17 @@ const driver = getDriver()
describe('file a report on a resource', () => {
let authenticatedUser, currentUser, mutate, query, moderator, abusiveUser, otherReportingUser
const categoryIds = ['cat9']
const reportMutation = gql`
const fileReportMutation = gql`
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
fileReport(
resourceId: $resourceId
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
id
createdAt
updatedAt
closed
rule
reasonCategory
reasonDescription
reportId
resource {
__typename
... on User {
@ -34,6 +33,35 @@ describe('file a report on a resource', () => {
content
}
}
}
}
`
const variables = {
resourceId: 'invalid',
reasonCategory: 'other',
reasonDescription: 'Violates code of conduct !!!',
}
const reportsQuery = gql`
query($closed: Boolean) {
reports(orderBy: createdAt_desc, closed: $closed) {
id
createdAt
updatedAt
rule
disable
closed
resource {
__typename
... on User {
id
}
... on Post {
id
}
... on Comment {
id
}
}
filed {
submitter {
id
@ -45,11 +73,31 @@ describe('file a report on a resource', () => {
}
}
`
const variables = {
resourceId: 'whatever',
reasonCategory: 'other',
reasonDescription: 'Violates code of conduct !!!',
}
const reviewMutation = gql`
mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) {
review(resourceId: $resourceId, disable: $disable, closed: $closed) {
createdAt
resource {
__typename
... on User {
id
disabled
}
... on Post {
id
disabled
}
... on Comment {
id
disabled
}
}
report {
disable
}
}
}
`
beforeAll(async () => {
await cleanDatabase()
@ -74,7 +122,7 @@ describe('file a report on a resource', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({
await expect(mutate({ mutation: fileReportMutation, variables })).resolves.toMatchObject({
data: { fileReport: null },
errors: [{ message: 'Not Authorised!' }],
})
@ -94,6 +142,17 @@ describe('file a report on a resource', () => {
password: '1234',
},
)
moderator = await Factory.build(
'user',
{
id: 'moderator-id',
role: 'moderator',
},
{
email: 'moderator@example.org',
password: '1234',
},
)
otherReportingUser = await Factory.build(
'user',
{
@ -127,7 +186,7 @@ describe('file a report on a resource', () => {
describe('invalid resource id', () => {
it('returns null', async () => {
await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({
await expect(mutate({ mutation: fileReportMutation, variables })).resolves.toMatchObject({
data: { fileReport: null },
errors: undefined,
})
@ -139,47 +198,112 @@ describe('file a report on a resource', () => {
it('which belongs to resource', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
data: {
fileReport: {
id: expect.any(String),
reportId: expect.any(String),
resource: {
name: 'abusive-user',
},
},
},
errors: undefined,
})
})
it('creates only one report for multiple reports on the same resource', async () => {
it('only one report for multiple reports on the same resource', async () => {
const firstReport = await mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
authenticatedUser = await otherReportingUser.toJson()
const secondReport = await mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
expect(firstReport.data.fileReport.id).toEqual(secondReport.data.fileReport.id)
expect(firstReport.data.fileReport.reportId).toEqual(
secondReport.data.fileReport.reportId,
)
})
it('returns the rule for how the report was decided', async () => {
await expect(
mutate({
mutation: reportMutation,
describe('report properties are set correctly', () => {
const reportsCypherQuery =
'MATCH (resource:User {id: $resourceId})<-[:BELONGS_TO]-(report:Report {closed: false})<-[filed:FILED]-(user:User {id: $currentUserId}) RETURN report'
it('with the rule for how the report will be decided', async () => {
await mutate({
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
data: {
fileReport: {
rule: 'latestReviewUpdatedAtRules',
},
},
errors: undefined,
})
const reportsCypherQueryResponse = await instance.cypher(reportsCypherQuery, {
resourceId: 'abusive-user-id',
currentUserId: authenticatedUser.id,
})
expect(reportsCypherQueryResponse.records).toHaveLength(1)
const [reportProperties] = reportsCypherQueryResponse.records.map(
record => record.get('report').properties,
)
expect(reportProperties).toMatchObject({ rule: 'latestReviewUpdatedAtRules' })
})
describe('with overtaken disabled from resource in disable property', () => {
it('disable is false', async () => {
await mutate({
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
const reportsCypherQueryResponse = await instance.cypher(reportsCypherQuery, {
resourceId: 'abusive-user-id',
currentUserId: authenticatedUser.id,
})
expect(reportsCypherQueryResponse.records).toHaveLength(1)
const [reportProperties] = reportsCypherQueryResponse.records.map(
record => record.get('report').properties,
)
expect(reportProperties).toMatchObject({ disable: false })
})
it('disable is true', async () => {
// first time filling a report to enable a moderator the disable the resource
await mutate({
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
authenticatedUser = await moderator.toJson()
await mutate({
mutation: reviewMutation,
variables: {
resourceId: 'abusive-user-id',
disable: true,
closed: true,
},
})
authenticatedUser = await currentUser.toJson()
// second time filling a report to see if the "disable is true" of the resource is overtaken
await mutate({
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
})
const reportsCypherQueryResponse = await instance.cypher(reportsCypherQuery, {
resourceId: 'abusive-user-id',
currentUserId: authenticatedUser.id,
})
expect(reportsCypherQueryResponse.records).toHaveLength(1)
const [reportProperties] = reportsCypherQueryResponse.records.map(
record => record.get('report').properties,
)
expect(reportProperties).toMatchObject({ disable: true })
})
})
})
it.todo('creates multiple filed reports')
})
@ -187,7 +311,7 @@ describe('file a report on a resource', () => {
it('returns __typename "User"', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
@ -205,7 +329,7 @@ describe('file a report on a resource', () => {
it('returns user attribute info', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
@ -221,32 +345,10 @@ describe('file a report on a resource', () => {
})
})
it('returns the submitter', async () => {
it('returns a createdAt', async () => {
await expect(
mutate({
mutation: reportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
data: {
fileReport: {
filed: [
{
submitter: {
id: 'current-user-id',
},
},
],
},
},
errors: undefined,
})
})
it('returns a date', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: { ...variables, resourceId: 'abusive-user-id' },
}),
).resolves.toMatchObject({
@ -262,7 +364,7 @@ describe('file a report on a resource', () => {
it('returns the reason category', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'abusive-user-id',
@ -272,11 +374,7 @@ describe('file a report on a resource', () => {
).resolves.toMatchObject({
data: {
fileReport: {
filed: [
{
reasonCategory: 'criminal_behavior_violation_german_law',
},
],
reasonCategory: 'criminal_behavior_violation_german_law',
},
},
errors: undefined,
@ -286,7 +384,7 @@ describe('file a report on a resource', () => {
it('gives an error if the reason category is not in enum "ReasonCategory"', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'abusive-user-id',
@ -307,7 +405,7 @@ describe('file a report on a resource', () => {
it('returns the reason description', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'abusive-user-id',
@ -317,11 +415,7 @@ describe('file a report on a resource', () => {
).resolves.toMatchObject({
data: {
fileReport: {
filed: [
{
reasonDescription: 'My reason!',
},
],
reasonDescription: 'My reason!',
},
},
errors: undefined,
@ -331,7 +425,7 @@ describe('file a report on a resource', () => {
it('sanitizes the reason description', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'abusive-user-id',
@ -341,11 +435,7 @@ describe('file a report on a resource', () => {
).resolves.toMatchObject({
data: {
fileReport: {
filed: [
{
reasonDescription: 'My reason !',
},
],
reasonDescription: 'My reason !',
},
},
errors: undefined,
@ -371,7 +461,7 @@ describe('file a report on a resource', () => {
it('returns type "Post"', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'post-to-report-id',
@ -392,7 +482,7 @@ describe('file a report on a resource', () => {
it('returns resource in post attribute', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'post-to-report-id',
@ -442,7 +532,7 @@ describe('file a report on a resource', () => {
it('returns type "Comment"', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'comment-to-report-id',
@ -463,7 +553,7 @@ describe('file a report on a resource', () => {
it('returns resource in comment attribute', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'comment-to-report-id',
@ -493,7 +583,7 @@ describe('file a report on a resource', () => {
it('returns null', async () => {
await expect(
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
...variables,
resourceId: 'tag-to-report-id',
@ -510,37 +600,6 @@ describe('file a report on a resource', () => {
})
describe('query for reported resource', () => {
const reportsQuery = gql`
query {
reports(orderBy: createdAt_desc) {
id
createdAt
updatedAt
closed
resource {
__typename
... on User {
id
}
... on Post {
id
}
... on Comment {
id
}
}
filed {
submitter {
id
}
createdAt
reasonCategory
reasonDescription
}
}
}
`
beforeEach(async () => {
authenticatedUser = null
moderator = await Factory.build(
@ -632,7 +691,7 @@ describe('file a report on a resource', () => {
authenticatedUser = await currentUser.toJson()
await Promise.all([
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
resourceId: 'abusive-post-1',
reasonCategory: 'other',
@ -640,7 +699,7 @@ describe('file a report on a resource', () => {
},
}),
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
resourceId: 'abusive-comment-1',
reasonCategory: 'discrimination_etc',
@ -648,7 +707,7 @@ describe('file a report on a resource', () => {
},
}),
mutate({
mutation: reportMutation,
mutation: fileReportMutation,
variables: {
resourceId: 'abusive-user-1',
reasonCategory: 'doxing',

View File

@ -251,12 +251,12 @@ export default {
boolean: {
followedByCurrentUser:
'MATCH (this)<-[:FOLLOWS]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
isBlocked:
'MATCH (this)<-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
blocked:
'MATCH (this)-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
isMuted:
'MATCH (this)<-[:MUTED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
isBlocked:
'MATCH (this)<-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
},
count: {
contributionsCount:

View File

@ -16,3 +16,15 @@ enum ReasonCategory {
advert_products_services_commercial
criminal_behavior_violation_german_law
}
type FiledReport {
createdAt: String!
reasonCategory: ReasonCategory!
reasonDescription: String!
reportId: ID!
resource: ReportedResource!
}
type Mutation {
fileReport(resourceId: ID!, reasonCategory: ReasonCategory!, reasonDescription: String!): FiledReport
}

View File

@ -4,7 +4,6 @@ type REVIEWED {
disable: Boolean!
closed: Boolean!
report: Report
# @cypher(statement: "MATCH (report:Report)<-[this:REVIEWED]-(:User) RETURN report")
moderator: User
resource: ReviewedResource
}

View File

@ -5,9 +5,9 @@ type Report {
rule: ReportRule!
disable: Boolean!
closed: Boolean!
filed: [FILED]
filed: [FILED]!
reviewed: [REVIEWED]!
resource: ReportedResource
resource: ReportedResource!
}
union ReportedResource = User | Post | Comment
@ -16,10 +16,6 @@ enum ReportRule {
latestReviewUpdatedAtRules
}
type Mutation {
fileReport(resourceId: ID!, reasonCategory: ReasonCategory!, reasonDescription: String!): Report
}
type Query {
reports(orderBy: ReportOrdering, first: Int, offset: Int, reviewed: Boolean, closed: Boolean): [Report]
}

View File

@ -64,10 +64,11 @@ type User {
# Is the currently logged in user following that user?
followedByCurrentUser: Boolean! @cypher(
statement: """
MATCH (this)<-[: FOLLOWS]-(u: User { id: $cypherParams.currentUserId})
MATCH (this)<-[:FOLLOWS]-(u:User { id: $cypherParams.currentUserId})
RETURN COUNT(u) >= 1
"""
)
isBlocked: Boolean! @cypher(
statement: """
MATCH (this)<-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId})

View File

@ -139,7 +139,7 @@ Given('somebody reported the following posts:', table => {
.authenticateAs(submitter)
.mutate(gql`mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
fileReport(resourceId: $resourceId, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription) {
id
reportId
}
}`, {
resourceId,

View File

@ -44,13 +44,22 @@ export default {
computed: {
filterOptions() {
return [
{ label: this.$t('moderation.reports.filterLabel.all'), value: { reviewed: null } },
{
label: this.$t('moderation.reports.filterLabel.all'),
value: { reviewed: null, closed: null },
},
{
label: this.$t('moderation.reports.filterLabel.unreviewed'),
value: { reviewed: false },
value: { reviewed: false, closed: false },
},
{
label: this.$t('moderation.reports.filterLabel.reviewed'),
value: { reviewed: true, closed: false },
},
{
label: this.$t('moderation.reports.filterLabel.closed'),
value: { reviewed: null, closed: true },
},
{ label: this.$t('moderation.reports.filterLabel.reviewed'), value: { reviewed: true } },
{ label: this.$t('moderation.reports.filterLabel.closed'), value: { closed: true } },
]
},
modalData() {
@ -108,13 +117,8 @@ export default {
filter(option) {
this.selected = option.label
this.offset = 0
if (option.value.closed) {
this.closed = option.value.closed
this.reviewed = null
return
}
this.closed = null
this.reviewed = option.value.reviewed
this.closed = option.value.closed
},
async confirmCallback(resource) {
const { disabled: disable, id: resourceId } = resource

View File

@ -24,11 +24,8 @@
</tr>
</thead>
<template v-for="report in reports">
<report-row
:key="report.resource.id"
:report="report"
@confirm-report="$emit('confirm', report)"
/>
<!-- should be ':key="report.resource.id"' for having one element for every resource, but this crashes at the moment, because the 'reports' query returns multiple reports on the same resource! I will create an issue -->
<report-row :key="report.id" :report="report" @confirm-report="$emit('confirm', report)" />
</template>
</table>
<hc-empty v-else icon="alert" :message="$t('moderation.reports.empty')" />

View File

@ -100,7 +100,7 @@ export const reportMutation = () => {
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
id
reportId
}
}
`