Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
04a832e
super rough test
Oct 10, 2023
6d3cc9e
verbose test output
Oct 10, 2023
f8c006e
track responses in collection
Oct 12, 2023
46f4b5e
consistent vocabulary
Oct 12, 2023
410cb38
remove code committed in error
Oct 12, 2023
a6b5d48
start testing question colletions get updated
Oct 12, 2023
724f27b
wip updating reply collection
Oct 13, 2023
65106c8
update original object to reflect votes
Oct 17, 2023
2590bb3
solve voterCount for the most simple test case
Oct 17, 2023
5d7ac60
write a test for and support anyOf
Oct 17, 2023
6f40b76
test and implementation for publishing results
Oct 18, 2023
ae063a1
test to validate voting is blocked on closed polls
Oct 18, 2023
8103fc0
adjust order so linked property is available for more validations
Oct 18, 2023
29ecafb
validate the polls are still open
Oct 18, 2023
a403aa6
write test to prevent a user from voting for the same option twice
Oct 18, 2023
ed26bfe
refactor variable name
Oct 19, 2023
4b060c7
test cleanup, put in basis of incomplete change
Oct 20, 2023
2f56463
get test to fail as expected
Oct 23, 2023
1ad5bbb
cleanup and comment, to return
Oct 23, 2023
ba5d724
have resolve thread get meta
Oct 27, 2023
6c44661
change how voters are counted, remove validations from side effects
Oct 27, 2023
553eabf
implement oneOf, WIP anyOf
Oct 27, 2023
d557f0a
fix other validations
Oct 30, 2023
777848f
refactor unneeded code
Oct 30, 2023
15e12b9
cleanup white space, comments
Oct 30, 2023
5f209b4
lint auto fixed
Oct 30, 2023
9f1e1fb
manual lint fixes
Oct 30, 2023
b6b568f
correctly resolve lint issue
Oct 30, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions net/activity.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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