Skip to content

Commit

Permalink
Merge b6b568f into fd48013
Browse files Browse the repository at this point in the history
  • Loading branch information
aspalding committed Dec 22, 2023
2 parents fd48013 + b6b568f commit c06a3ce
Show file tree
Hide file tree
Showing 8 changed files with 290 additions and 9 deletions.
43 changes: 40 additions & 3 deletions net/activity.js
Expand Up @@ -81,6 +81,7 @@ module.exports = {
let activity = req.body
let object = resLocal.object
resLocal.status = 200
let question
/* isNewActivity:
false - this inbox has seen this activity before
'new collection' - known activity, new inbox
Expand Down Expand Up @@ -189,6 +190,45 @@ module.exports = {
}
}
break
case 'create':
question = resLocal.linked.find(({ type }) => type.toLowerCase() === 'question')
if (question) {
let questionType
const targetActivity = object
const targetActivityChoice = targetActivity.name[0].toLowerCase()
if (Object.hasOwn(question, 'oneOf')) {
questionType = 'oneOf'
} else if (Object.hasOwn(question, 'anyOf')) {
questionType = 'anyOf'
}
const chosenCollection = question[questionType].find(({ name }) => name[0].toLowerCase() === targetActivityChoice)
const chosenCollectionId = apex.objectIdFromValue(chosenCollection.replies)
toDo.push((async () => {
activity = await apex.store.updateActivityMeta(activity, 'collection', chosenCollectionId)
const updatedCollection = await apex.getCollection(chosenCollectionId)
question[questionType].find(({ replies }) => replies.id === chosenCollectionId).replies = updatedCollection
if (question._meta) {
question._meta.voteAndVoter[0].push({
voter: activity.actor[0],
voteName: activity.object[0].name[0]
})
question.votersCount = [...new Set(question._meta.voteAndVoter[0].map(obj => obj.voter))].length
} else {
const voteAndVoter = [{
voter: activity.actor[0],
voteName: activity.object[0].name[0]
}]
question.votersCount = 1
apex.addMeta(question, 'voteAndVoter', voteAndVoter)
}
const updatedQuestion = await apex.store.updateObject(question, actorId, true)
if (updatedQuestion) {
resLocal.postWork.push(async () => {
return apex.publishUpdate(recipient, updatedQuestion, actorId)
})
}
})())
}
}
Promise.all(toDo).then(() => {
// configure event hook to be triggered after response sent
Expand Down Expand Up @@ -306,9 +346,6 @@ module.exports = {
resolveThread (req, res, next) {
const apex = req.app.locals.apex
const resLocal = res.locals.apex
if (!resLocal.activity) {
return next()
}
apex.resolveReferences(req.body).then(refs => {
resLocal.linked = refs
next()
Expand Down
2 changes: 1 addition & 1 deletion net/index.js
Expand Up @@ -68,11 +68,11 @@ module.exports = {
validators.jsonld,
validators.targetActorWithMeta,
security.verifySignature,
activity.resolveThread,
validators.actor,
validators.activityObject,
validators.inboxActivity,
activity.save,
activity.resolveThread,
activity.inboxSideEffects,
activity.forwardFromInbox,
responders.status
Expand Down
23 changes: 23 additions & 0 deletions net/validators.js
Expand Up @@ -136,6 +136,29 @@ function inboxActivity (req, res, next) {
return next()
}
}
const question = resLocal.linked.find(({ type }) => type.toLowerCase() === 'question')
if (question) {
const now = new Date()
const pollEndTime = new Date(question.endTime)
if (now > pollEndTime) {
resLocal.status = 403
next()
}
if (Object.hasOwn(question, 'oneOf')) {
if (question._meta?.voteAndVoter[0].map(obj => obj.voter).includes(activity.actor[0])) {
resLocal.status = 403
next()
}
} else if (Object.hasOwn(question, 'anyOf')) {
const hasDuplicateVote = question._meta?.voteAndVoter[0].some(({ voter, voteName }) => {
return voter === activity.actor[0] && activity.object[0].name[0] === voteName
})
if (hasDuplicateVote) {
resLocal.status = 403
next()
}
}
}
tasks.push(apex.embedCollections(activity))
Promise.all(tasks).then(() => {
apex.addMeta(req.body, 'collection', recipient.inbox[0])
Expand Down
2 changes: 1 addition & 1 deletion pub/federation.js
Expand Up @@ -44,7 +44,7 @@ const refProps = ['inReplyTo', 'object', 'target', 'tag']
async function resolveReferences (object, depth = 0) {
const objectPromises = refProps.map(prop => object[prop])
.flat() // may have multiple tags to resolve
.map(o => this.resolveUnknown(o))
.map(o => this.resolveUnknown(o, true))
.filter(p => p)
const objects = (await Promise.allSettled(objectPromises))
.filter(r => r.status === 'fulfilled' && r.value)
Expand Down
6 changes: 3 additions & 3 deletions pub/object.js
Expand Up @@ -36,7 +36,7 @@ async function resolveObject (id, includeMeta, refresh, localOnly) {
return object
}

async function resolveUnknown (objectOrIRI) {
async function resolveUnknown (objectOrIRI, includeMeta) {
let object
if (!objectOrIRI) return null
// For Link/Mention, we want to resolved the linked object
Expand All @@ -45,9 +45,9 @@ async function resolveUnknown (objectOrIRI) {
}
// check if already cached
if (this.isString(objectOrIRI)) {
object = await this.store.getActivity(objectOrIRI)
object = await this.store.getActivity(objectOrIRI, includeMeta)
if (object) return object
object = await this.store.getObject(objectOrIRI)
object = await this.store.getObject(objectOrIRI, includeMeta)
if (object) return object
/* As local collections are not represented in the DB, instead being generated
* on demand, they up getting requested via http below. Perhaps not the most efficient,
Expand Down
219 changes: 219 additions & 0 deletions spec/functional/inbox.spec.js
Expand Up @@ -1510,4 +1510,223 @@ describe('inbox', function () {
.expect(400)
})
})

describe('question', function () {
let activity
let question
let reply
beforeEach(async function () {
question = {
type: 'Question',
id: 'https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19',
attributedTo: ['https://localhost/u/test'],
to: ['https://localhost/u/test'],
audience: ['as:Public'],
content: ['Say, did you finish reading that book I lent you?'],
votersCount: [0],
oneOf: [
{
type: 'Note',
name: ['Yes'],
replies: {
type: 'Collection',
id: 'https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19/votes/Yes',
totalItems: [0]
}
},
{
type: 'Note',
name: ['No'],
replies: {
type: 'Collection',
id: 'https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19/votes/No',
totalItems: [0]
}
}
]
}
activity = {
'@context': ['https://www.w3.org/ns/activitystreams'],
type: 'Create',
id: 'https://localhost/s/a29a6843-9feb-4c74-a7f7-081b9c9201d3',
to: ['https://localhost/u/test'],
audience: ['as:Public'],
actor: ['https://localhost/u/test'],
object: [question],
shares: [{
id: 'https://localhost/s/a29a6843-9feb-4c74-a7f7-081b9c9201d3/shares',
type: 'OrderedCollection',
totalItems: [0],
first: ['https://localhost/s/a29a6843-9feb-4c74-a7f7-081b9c9201d3/shares?page=true']
}],
likes: [{
id: 'https://localhost/s/a29a6843-9feb-4c74-a7f7-081b9c9201d3/likes',
type: 'OrderedCollection',
totalItems: [0],
first: ['https://localhost/s/a29a6843-9feb-4c74-a7f7-081b9c9201d3/likes?page=true']
}]
}
reply = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'https://localhost/s/2131231',
to: 'https://localhost/u/test',
actor: 'https://localhost/u/test',
type: 'Create',
object: {
id: 'https://localhost/o/2131231',
type: 'Note',
name: 'Yes',
attributedTo: 'https://localhost/u/test',
to: 'https://localhost/u/test',
inReplyTo: 'https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19'
}
}
await apex.store.saveActivity(activity)
await apex.store.saveObject(question)
})
it('tracks replies in a collection', async function () {
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(reply)
.expect(200)

const storedReply = await apex.store.getActivity(reply.id, true)
expect(storedReply._meta.collection).toContain('https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19/votes/Yes')
})
it('the question replies collection is updated', async function () {
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(reply)
.expect(200)

const questionStored = await apex.store.getObject(question.id, true)
const chosenCollection = questionStored.oneOf.find(({ name }) => name[0].toLowerCase() === 'yes')
expect(chosenCollection.replies.totalItems[0]).toBe(1)
})
it('keeps a voterCount tally', async function () {
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(reply)
.expect(200)
let questionStored = await apex.store.getObject(question.id, true)
expect(questionStored.votersCount).toEqual(1)
const anotherVoter = await apex.createActor('voter', 'voter', 'voting user')
await apex.store.saveObject(anotherVoter)
const anotherReply = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'https://localhost/s/2131232',
to: 'https://localhost/u/test',
actor: 'https://localhost/u/voter',
type: 'Create',
object: {
id: 'https://localhost/o/2131232',
type: 'Note',
name: 'Yes',
attributedTo: 'https://localhost/u/voter',
to: 'https://localhost/u/test',
inReplyTo: 'https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19'
}
}
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(anotherReply)
.expect(200)
questionStored = await apex.store.getObject(question.id, true)
expect(questionStored.votersCount).toEqual(2)
})
it('anyOf property allows a user to vote for multiple choices', async function () {
question.anyOf = question.oneOf
delete question.oneOf
await apex.store.updateObject(question, 'test', true)
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(reply)
.expect(200)
reply.id = 'https://localhost/s/2131232'
reply.object.id = 'https://localhost/o/2131232'
reply.object.name = 'No'
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(reply)
.expect(200)
let questionStored = await apex.store.getObject(question.id, true)
const yesCollection = questionStored.anyOf.find(({ name }) => name[0].toLowerCase() === 'yes')
expect(yesCollection.replies.totalItems[0]).toBe(1)
const noCollection = questionStored.anyOf.find(({ name }) => name[0].toLowerCase() === 'no')
expect(noCollection.replies.totalItems[0]).toBe(1)
questionStored = await apex.store.getObject(question.id, true)
expect(questionStored.votersCount).toEqual(1)
})
it('publishes the results', async function () {
const addrSpy = spyOn(apex, 'address').and.callFake(async () => ['https://ignore.com/inbox/ignored'])
const requestValidated = new Promise(resolve => {
nock('https://mocked.com').post('/inbox/mocked')
.reply(200)
.on('request', async (req, interceptor, body) => {
const sentActivity = JSON.parse(body)
expect(sentActivity.object.votersCount).toEqual(1)
expect(sentActivity.object.oneOf.find(({ name }) => name.toLowerCase() === 'yes').replies.totalItems).toEqual(1)
resolve()
})
})
addrSpy.and.callFake(async () => ['https://mocked.com/inbox/mocked'])
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/activity+json')
.send(reply)
.expect(200)
await requestValidated
})
describe('validations', function () {
it('wont allow a vote to a closed poll', async function () {
const closedDate = new Date()
closedDate.setDate(closedDate.getDate() - 1)
question.endTime = closedDate
await apex.store.updateObject(question, 'test', true)
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(reply)
.expect(403)
})
it('prevents the same user from voting for the same choice twice', async function () {
question.anyOf = question.oneOf
delete question.oneOf
await apex.store.updateObject(question, 'test', true)
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(reply)
.expect(200)
reply.id = 'https://localhost/s/2131232'
reply.object.id = 'https://localhost/o/2131232'
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(reply)
.expect(403)
})
it('oneOf prevents the same user from voting for multiple choices', async function () {
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(reply)
.expect(200)
reply.id = 'https://localhost/s/2131232'
reply.object.id = 'https://localhost/o/2131232'
reply.object.name = 'No'
await request(app)
.post('/inbox/test')
.set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
.send(reply)
.expect(403)
})
})
})
})
3 changes: 2 additions & 1 deletion spec/helpers/reporter.js
Expand Up @@ -4,7 +4,8 @@ const SpecReporter = require('jasmine-spec-reporter').SpecReporter
jasmine.getEnv().clearReporters() // remove default reporter logs
jasmine.getEnv().addReporter(new SpecReporter({ // add jasmine-spec-reporter
spec: {
displayPending: true
displayPending: true,
displayStacktrace: 'pretty'
},
summary: {
displayDuration: false
Expand Down
1 change: 1 addition & 0 deletions spec/helpers/test-utils.js
Expand Up @@ -18,6 +18,7 @@ global.initApex = async function initApex () {
blocked: '/u/:actor/blocked',
rejections: '/u/:actor/rejections',
rejected: '/u/:actor/rejected',
votes: '/o/:question/c/:id',
nodeinfo: '/nodeinfo'
}
const app = express()
Expand Down

0 comments on commit c06a3ce

Please sign in to comment.