From 35b5119df40680af48b67f6088e74ec8431c63a8 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 15 Aug 2024 16:27:16 -0400 Subject: [PATCH] handle multiple recipient and author record queries --- src/handlers/records-query.ts | 4 +- src/handlers/records-subscribe.ts | 8 +- src/utils/records.ts | 10 + tests/handlers/records-query.spec.ts | 283 ++++++++++++++++++++++- tests/handlers/records-read.spec.ts | 2 +- tests/scenarios/aggregator.spec.ts | 23 +- tests/scenarios/end-to-end-tests.spec.ts | 2 +- tests/scenarios/subscriptions.spec.ts | 2 +- 8 files changed, 320 insertions(+), 14 deletions(-) diff --git a/src/handlers/records-query.ts b/src/handlers/records-query.ts index 84a391ffa..b90754a65 100644 --- a/src/handlers/records-query.ts +++ b/src/handlers/records-query.ts @@ -157,7 +157,9 @@ export class RecordsQueryHandler implements MethodHandler { if (Records.shouldProtocolAuthorize(recordsQuery.signaturePayload!)) { filters.push(RecordsQueryHandler.buildUnpublishedProtocolAuthorizedRecordsFilter(recordsQuery)); - } else if (Records.shouldBuildUnpublishedRecipientFilter(filter, recordsQuery.author!)) { + } + + if (Records.shouldBuildUnpublishedRecipientFilter(filter, recordsQuery.author!)) { filters.push(RecordsQueryHandler.buildUnpublishedRecordsForQueryAuthorFilter(recordsQuery)); } } diff --git a/src/handlers/records-subscribe.ts b/src/handlers/records-subscribe.ts index 081709ae6..850e7a6c1 100644 --- a/src/handlers/records-subscribe.ts +++ b/src/handlers/records-subscribe.ts @@ -127,11 +127,15 @@ export class RecordsSubscribeHandler implements MethodHandler { } if (Records.filterIncludesUnpublishedRecords(filter)) { - filters.push(RecordsSubscribeHandler.buildUnpublishedRecordsBySubscribeAuthorFilter(recordsSubscribe)); + if (Records.shouldBuildUnpublishedAuthorFilter(filter, recordsSubscribe.author!)) { + filters.push(RecordsSubscribeHandler.buildUnpublishedRecordsBySubscribeAuthorFilter(recordsSubscribe)); + } if (Records.shouldProtocolAuthorize(recordsSubscribe.signaturePayload!)) { filters.push(RecordsSubscribeHandler.buildUnpublishedProtocolAuthorizedRecordsFilter(recordsSubscribe)); - } else if (Records.shouldBuildUnpublishedRecipientFilter(filter, recordsSubscribe.author!)) { + } + + if (Records.shouldBuildUnpublishedRecipientFilter(filter, recordsSubscribe.author!)) { filters.push(RecordsSubscribeHandler.buildUnpublishedRecordsForSubscribeAuthorFilter(recordsSubscribe)); } } diff --git a/src/utils/records.ts b/src/utils/records.ts index 377dd4831..3bbf60753 100644 --- a/src/utils/records.ts +++ b/src/utils/records.ts @@ -382,6 +382,16 @@ export class Records { filterCopy.contextId = contextIdPrefixFilter; } + // if the author filter is an array and it's empty, we should remove it from the filter as it will always return no results. + if (Array.isArray(filterCopy.author) && filterCopy.author.length === 0) { + delete filterCopy.author; + } + + // if the recipient filter is an array and it's empty, we should remove it from the filter as it will always return no results. + if (Array.isArray(filterCopy.recipient) && filterCopy.recipient.length === 0) { + delete filterCopy.recipient; + } + return filterCopy as Filter; } diff --git a/tests/handlers/records-query.spec.ts b/tests/handlers/records-query.spec.ts index ee29d2ec8..8eb094ad8 100644 --- a/tests/handlers/records-query.spec.ts +++ b/tests/handlers/records-query.spec.ts @@ -305,7 +305,7 @@ export function testRecordsQueryHandler(): void { recordsQuery = await TestDataGenerator.generateRecordsQuery({ author : alice, filter : { - author : [ bob.did ], + author : bob.did, protocol : protocolDefinition.protocol, schema : protocolDefinition.types.post.schema, dataFormat : protocolDefinition.types.post.dataFormats[0], @@ -318,6 +318,281 @@ export function testRecordsQueryHandler(): void { expect(queryReply.entries![0].recordId).to.equal(bobAuthorWrite.message.recordId); }); + it('should be able to query by multiple authors', async () => { + // scenario: alice, bob and carol author records into alice's DWN. + // alice is able to query based on multiple authors. + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + const carol = await TestDataGenerator.generateDidKeyPersona(); + + const protocolDefinition = freeForAll; + + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + const aliceAuthorWrite = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + }); + const aliceAuthorReply = await dwn.processMessage(alice.did, aliceAuthorWrite.message, { dataStream: aliceAuthorWrite.dataStream }); + expect(aliceAuthorReply.status.code).to.equal(202); + + const bobAuthorWrite = await TestDataGenerator.generateRecordsWrite({ + author : bob, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + }); + const bobAuthorReply = await dwn.processMessage(alice.did, bobAuthorWrite.message, { dataStream: bobAuthorWrite.dataStream }); + expect(bobAuthorReply.status.code).to.equal(202); + + const carolAuthorWrite = await TestDataGenerator.generateRecordsWrite({ + author : carol, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + }); + const carolAuthorReply = await dwn.processMessage(alice.did, carolAuthorWrite.message, { dataStream: carolAuthorWrite.dataStream }); + expect(carolAuthorReply.status.code).to.equal(202); + + // alice queries with an empty array, gets all + let recordsQuery = await TestDataGenerator.generateRecordsQuery({ + author : alice, + filter : { + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post', + author : [] + } + }); + let queryReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(queryReply.status.code).to.equal(200); + expect(queryReply.entries?.length).to.equal(3); + expect(queryReply.entries?.map(e => e.recordId)).to.have.members([ + aliceAuthorWrite.message.recordId, + bobAuthorWrite.message.recordId, + carolAuthorWrite.message.recordId + ]); + + // filter for alice and bob as authors + recordsQuery = await TestDataGenerator.generateRecordsQuery({ + author : alice, + filter : { + author : [alice.did, bob.did], + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + } + }); + queryReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(queryReply.status.code).to.equal(200); + expect(queryReply.entries?.length).to.equal(2); + expect(queryReply.entries?.map(e => e.recordId)).to.have.members([ + aliceAuthorWrite.message.recordId, + bobAuthorWrite.message.recordId + ]); + }); + + it('should be able to query by recipient', async () => { + // scenario alice authors records for bob and carol into alice's DWN. + // bob and carol are able to filter for records for them. + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + const carol = await TestDataGenerator.generateDidKeyPersona(); + + const protocolDefinition = freeForAll; + + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + const aliceToBob = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + }); + const aliceToBobReply = await dwn.processMessage(alice.did, aliceToBob.message, { dataStream: aliceToBob.dataStream }); + expect(aliceToBobReply.status.code).to.equal(202); + + const aliceToCarol = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : carol.did, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + }); + const aliceToCarolReply = await dwn.processMessage(alice.did, aliceToCarol.message, { dataStream: aliceToCarol.dataStream }); + expect(aliceToCarolReply.status.code).to.equal(202); + + // alice queries with an empty filter, gets both + let recordsQuery = await TestDataGenerator.generateRecordsQuery({ + author : alice, + filter : { + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + } + }); + let queryReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(queryReply.status.code).to.equal(200); + expect(queryReply.entries?.length).to.equal(2); + + // filter for bob as recipient + recordsQuery = await TestDataGenerator.generateRecordsQuery({ + author : alice, + filter : { + recipient : bob.did, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + } + }); + queryReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(queryReply.status.code).to.equal(200); + expect(queryReply.entries?.length).to.equal(1); + expect(queryReply.entries![0].recordId).to.equal(aliceToBob.message.recordId); + + // filter for carol as recipient + recordsQuery = await TestDataGenerator.generateRecordsQuery({ + author : alice, + filter : { + recipient : carol.did, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + } + }); + queryReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(queryReply.status.code).to.equal(200); + expect(queryReply.entries?.length).to.equal(1); + expect(queryReply.entries![0].recordId).to.equal(aliceToCarol.message.recordId); + }); + + it('should be able to query by multiple recipients', async () => { + // scenario: alice, bob and carol author records for various recipients into alice's DWN. + // alice is able to filter based on multiple recipients + + const alice = await TestDataGenerator.generateDidKeyPersona(); + const bob = await TestDataGenerator.generateDidKeyPersona(); + const carol = await TestDataGenerator.generateDidKeyPersona(); + + const protocolDefinition = freeForAll; + + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: alice, + protocolDefinition + }); + const protocolsConfigureReply = await dwn.processMessage(alice.did, protocolsConfig.message); + expect(protocolsConfigureReply.status.code).to.equal(202); + + const bobToAliceWrite = await TestDataGenerator.generateRecordsWrite({ + author : bob, + recipient : alice.did, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + }); + const bobToAliceReply = await dwn.processMessage(alice.did, bobToAliceWrite.message, { dataStream: bobToAliceWrite.dataStream }); + expect(bobToAliceReply.status.code).to.equal(202); + + const aliceToBobWrite = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + }); + const aliceToBobReply = await dwn.processMessage(alice.did, aliceToBobWrite.message, { dataStream: aliceToBobWrite.dataStream }); + expect(aliceToBobReply.status.code).to.equal(202); + + const carolToBobWrite = await TestDataGenerator.generateRecordsWrite({ + author : carol, + recipient : bob.did, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + }); + const carolToBobReply = await dwn.processMessage(alice.did, carolToBobWrite.message, { dataStream: carolToBobWrite.dataStream }); + expect(carolToBobReply.status.code).to.equal(202); + + const aliceToCarolWrite = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : carol.did, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + }); + const aliceToCarolReply = await dwn.processMessage(alice.did, aliceToCarolWrite.message, { dataStream: aliceToCarolWrite.dataStream }); + expect(aliceToCarolReply.status.code).to.equal(202); + + // alice queries with an empty array, gets all + let recordsQuery = await TestDataGenerator.generateRecordsQuery({ + author : alice, + filter : { + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post', + recipient : [] + } + }); + let queryReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(queryReply.status.code).to.equal(200); + expect(queryReply.entries?.length).to.equal(4); + expect(queryReply.entries?.map(e => e.recordId)).to.have.members([ + bobToAliceWrite.message.recordId, + aliceToBobWrite.message.recordId, + carolToBobWrite.message.recordId, + aliceToCarolWrite.message.recordId + ]); + + // filter for alice and bob as authors + recordsQuery = await TestDataGenerator.generateRecordsQuery({ + author : alice, + filter : { + recipient : [alice.did, bob.did], + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.post.schema, + dataFormat : protocolDefinition.types.post.dataFormats[0], + protocolPath : 'post' + } + }); + queryReply = await dwn.processMessage(alice.did, recordsQuery.message); + expect(queryReply.status.code).to.equal(200); + expect(queryReply.entries?.length).to.equal(3); + expect(queryReply.entries?.map(e => e.recordId)).to.have.members([ + bobToAliceWrite.message.recordId, + aliceToBobWrite.message.recordId, + carolToBobWrite.message.recordId + ]); + }); + it('should be able to query for published records', async () => { const alice = await TestDataGenerator.generateDidKeyPersona(); const bob = await TestDataGenerator.generateDidKeyPersona(); @@ -1566,7 +1841,7 @@ export function testRecordsQueryHandler(): void { // filter for public records with carol as recipient const bobQueryCarolMessageData = await TestDataGenerator.generateRecordsQuery({ author : bob, - filter : { schema, recipient: [ carol.did ] } + filter : { schema, recipient: carol.did } }); const replyToBobCarolQuery = await dwn.processMessage(alice.did, bobQueryCarolMessageData.message); expect(replyToBobCarolQuery.status.code).to.equal(200); @@ -1576,7 +1851,7 @@ export function testRecordsQueryHandler(): void { // filter for explicit unpublished public records with carol as recipient, should not return any. const bobQueryCarolMessageDataUnpublished = await TestDataGenerator.generateRecordsQuery({ author : bob, - filter : { schema, recipient: [ carol.did ], published: false } + filter : { schema, recipient: carol.did, published: false } }); const replyToBobCarolUnpublishedQuery = await dwn.processMessage(alice.did, bobQueryCarolMessageDataUnpublished.message); expect(replyToBobCarolUnpublishedQuery.status.code).to.equal(200); @@ -1759,7 +2034,7 @@ export function testRecordsQueryHandler(): void { const bobQueryMessageData = await TestDataGenerator.generateRecordsQuery({ author : alice, - filter : { recipient: [ bob.did ] } // alice as the DWN owner querying bob's records + filter : { recipient: bob.did } // alice as the DWN owner querying bob's records }); const replyToBobQuery = await dwn.processMessage(alice.did, bobQueryMessageData.message); diff --git a/tests/handlers/records-read.spec.ts b/tests/handlers/records-read.spec.ts index c384a74c7..7bddd5f98 100644 --- a/tests/handlers/records-read.spec.ts +++ b/tests/handlers/records-read.spec.ts @@ -690,7 +690,7 @@ export function testRecordsReadHandler(): void { signer : Jws.createSigner(bob), filter : { protocolPath : 'thread/participant', - recipient : [ bob.did ], + recipient : bob.did, contextId : threadRecord.message.contextId }, }); diff --git a/tests/scenarios/aggregator.spec.ts b/tests/scenarios/aggregator.spec.ts index ba1c91f86..bb65f31b0 100644 --- a/tests/scenarios/aggregator.spec.ts +++ b/tests/scenarios/aggregator.spec.ts @@ -18,6 +18,11 @@ import { DidKey, UniversalResolver } from '@web5/dids'; chai.use(chaiAsPromised); +// This is a test suite that demonstrates how to use the DWN to create aggregators +// Aggregators allows multiple authors to write records to the aggregator's DID based on a role +// +// NOTE: This will be more evident when we introduce `signWithRole`. +// This would allow writing to your local DWN without any role field, but when writing to an aggregator, you could conform to their own roles. describe('Aggregator Model', () => { let didResolver: DidResolver; let messageStore: MessageStore; @@ -44,7 +49,8 @@ describe('Aggregator Model', () => { } }; - // A simple aggregator protocol that allows members of the aggregator to write notes to the aggregator and allow anyone to read or query + // A simple protocol that allows members of an aggregator to write notes to the aggregator + // Anyone can query or read public notes, the rest of the notes are enforced by `recipient/author` rules. const aggregatorProtocolDefinition:ProtocolDefinition = { protocol, published : true, @@ -100,7 +106,17 @@ describe('Aggregator Model', () => { await dwn.close(); }); - it('should support aggregation of records from multiple authors', async () => { + it('should support querying from multiple authors', async () => { + // scenario: Alice, Bob, Carol are members of an aggregator. + // Alice writes a note to Carol, Bob writes a note to Alice, Carol writes a note to Bob. + // Daniel is not a member of the aggregator and tries to unsuccessfully write a note to Alice. + // Daniel can query public notes from multiple authors in a single query. + // Alice and Bob create private notes with Carol as the recipient. + // Bob creates a private note to Alice. + // Daniel does not see the private notes in his query. + // Carol can see all notes from Alice and Bob in her query, including the private notes intended for her. + // Alice can see all notes from Bob and Carol in her query, including the private notes intended for her. + // create aggregator DID and install aggregator note protocol const aggregator = await TestDataGenerator.generateDidKeyPersona(); const aggregatorProtocolConfigure = await ProtocolsConfigure.create({ @@ -144,7 +160,6 @@ describe('Aggregator Model', () => { expect(danielProtocolReply.status.code).to.equal(202, 'daniel configure'); - // The aggregator creates member records for alice, bob and carol const aliceMemberData = TestDataGenerator.randomBytes(256); @@ -292,7 +307,7 @@ describe('Aggregator Model', () => { expect(danielReadReply.entries![0].recordId).to.equal(aliceNoteToCarol.message.recordId, 'daniel read alice note'); expect(danielReadReply.entries![1].recordId).to.equal(bobNoteToAlice.message.recordId, 'daniel read bob note'); - // create private notes to crol from alice and bob + // create private notes to carol from alice and bob const alicePrivateNoteToCarol = TestDataGenerator.randomBytes(256); const aliceNoteToCarolPrivate = await RecordsWrite.create({ signer : Jws.createSigner(alice), diff --git a/tests/scenarios/end-to-end-tests.spec.ts b/tests/scenarios/end-to-end-tests.spec.ts index abc874ed9..d4caa2c84 100644 --- a/tests/scenarios/end-to-end-tests.spec.ts +++ b/tests/scenarios/end-to-end-tests.spec.ts @@ -183,7 +183,7 @@ export function testEndToEndScenarios(): void { signer : Jws.createSigner(bob), filter : { protocolPath : 'thread/participant', - recipient : [ bob.did ], + recipient : bob.did, contextId : threadRecord.message.contextId }, }); diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts index 0458f202a..580e789e9 100644 --- a/tests/scenarios/subscriptions.spec.ts +++ b/tests/scenarios/subscriptions.spec.ts @@ -710,7 +710,7 @@ export function testSubscriptionScenarios(): void { const carolSubscribe = await TestDataGenerator.generateRecordsSubscribe({ author : carol, - filter : { schema: 'http://schema1', recipient: [ carol.did ] } + filter : { schema: 'http://schema1', recipient: carol.did } }); const carolSubscribeReply = await dwn.processMessage(alice.did, carolSubscribe.message, {