From a0b554cb0739a921f889cfb5ec1415b96f767835 Mon Sep 17 00:00:00 2001 From: elf Pavlik Date: Wed, 20 Dec 2023 14:12:27 -0600 Subject: [PATCH] [service] test: improve coverage Co-authored-by: Maciej Samoraj --- packages/service/src/handlers/api-handler.ts | 8 +- .../src/handlers/invitations-handler.ts | 17 +- packages/service/src/services/index.ts | 2 + packages/service/src/services/invitations.ts | 1 + .../test/unit/handlers/agents-handler-test.ts | 62 +++--- .../test/unit/handlers/api-handler-test.ts | 110 +++++++++- .../unit/handlers/invitations-handler-test.ts | 133 ++++++++++++ .../test/unit/services/access-request-test.ts | 30 +++ .../unit/services/data-registries-test.ts | 190 +++++++++++++----- .../test/unit/services/inivitations-test.ts | 170 ++++++++++++++++ 10 files changed, 630 insertions(+), 93 deletions(-) create mode 100644 packages/service/test/unit/handlers/invitations-handler-test.ts create mode 100644 packages/service/test/unit/services/access-request-test.ts create mode 100644 packages/service/test/unit/services/inivitations-test.ts diff --git a/packages/service/src/handlers/api-handler.ts b/packages/service/src/handlers/api-handler.ts index af0eb9e7..87bf2198 100644 --- a/packages/service/src/handlers/api-handler.ts +++ b/packages/service/src/handlers/api-handler.ts @@ -13,13 +13,15 @@ import { getUnregisteredApplicationProfile, getResource, shareResource, - listDataInstances + listDataInstances, + requestAccessUsingApplicationNeeds, + acceptInvitation, + createInvitation, + getSocialAgentInvitations } from '../services'; import type { SaiContext } from '../models/http-solid-context'; import { validateContentType } from '../utils/http-validators'; import { IReciprocalRegistrationsJobData } from '../models/jobs'; -import { requestAccessUsingApplicationNeeds } from '../services/access-request'; -import { acceptInvitation, createInvitation, getSocialAgentInvitations } from '../services/invitations'; export class ApiHandler extends HttpHandler { private logger = getLogger(); diff --git a/packages/service/src/handlers/invitations-handler.ts b/packages/service/src/handlers/invitations-handler.ts index 50234540..24e5d9b7 100644 --- a/packages/service/src/handlers/invitations-handler.ts +++ b/packages/service/src/handlers/invitations-handler.ts @@ -32,21 +32,20 @@ export class InvitationsHandler extends HttpHandler { socialAgentInvitation.label, socialAgentInvitation.note ); + // create job to discover, add and subscribe to reciprocal registration + await this.queue.add( + { + webId: userId, + registeredAgent: socialAgentRegistration.registeredAgent + } as IReciprocalRegistrationsJobData, + { delay: 10000 } + ); } // update invitation with agent who accepted it socialAgentInvitation.registeredAgent = socialAgentRegistration.registeredAgent; await socialAgentInvitation.update(); - // create job to discover, add and subscribe to reciprocal registration - await this.queue.add( - { - webId: userId, - registeredAgent: socialAgentRegistration.registeredAgent - } as IReciprocalRegistrationsJobData, - { delay: 10000 } - ); - return { body: userId, status: 200, diff --git a/packages/service/src/services/index.ts b/packages/service/src/services/index.ts index 77bdd989..565ebec0 100644 --- a/packages/service/src/services/index.ts +++ b/packages/service/src/services/index.ts @@ -4,3 +4,5 @@ export * from './descriptions'; export * from './social-agents'; export * from './web-push'; export * from './data-instance'; +export * from './access-request'; +export * from './invitations'; diff --git a/packages/service/src/services/invitations.ts b/packages/service/src/services/invitations.ts index a1b268fb..5fbda002 100644 --- a/packages/service/src/services/invitations.ts +++ b/packages/service/src/services/invitations.ts @@ -43,6 +43,7 @@ export async function acceptInvitation(saiSession: AuthorizationAgent, invitatio }); if (!response.ok) throw new Error('fetching capability url failed'); const webId = (await response.text()).trim(); + // TODO: validate with regex if (!webId) throw new Error('can not accept invitation without webid'); // check if agent already has registration let socialAgentRegistration = await saiSession.findSocialAgentRegistration(webId); diff --git a/packages/service/test/unit/handlers/agents-handler-test.ts b/packages/service/test/unit/handlers/agents-handler-test.ts index 395819e5..135ac57b 100644 --- a/packages/service/test/unit/handlers/agents-handler-test.ts +++ b/packages/service/test/unit/handlers/agents-handler-test.ts @@ -1,3 +1,11 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { jest } from '@jest/globals'; + +import { InMemoryStorage } from '@inrupt/solid-client-authn-node'; +import { HttpHandlerRequest } from '@digita-ai/handlersjs-http'; +import { AuthorizationAgent } from '@janeirodigital/interop-authorization-agent'; +import { INTEROP } from '@janeirodigital/interop-utils'; + import { AgentsHandler, SessionManager, @@ -7,26 +15,18 @@ import { webId2agentUrl, encodeWebId } from '../../../src'; -import { jest } from '@jest/globals'; jest.mock('../../../src/session-manager', () => { const originalModule = jest.requireActual('../../../src/session-manager') as object; return { ...originalModule, - SessionManager: jest.fn(() => { - return { - getSaiSession: jest.fn() - }; - }) + SessionManager: jest.fn(() => ({ + getSaiSession: jest.fn() + })) }; }); -import { AuthorizationAgent } from '@janeirodigital/interop-authorization-agent'; -import { INTEROP } from '@janeirodigital/interop-utils'; -import { InMemoryStorage } from '@inrupt/solid-client-authn-node'; -import { HttpHandlerRequest } from '@digita-ai/handlersjs-http'; - const aliceWebId = 'https://alice.example'; const aliceAgentUrl = webId2agentUrl(aliceWebId); @@ -70,15 +70,16 @@ describe('authenticated request', () => { test('application registration discovery', (done) => { const applicationRegistrationIri = 'https://some.example/application-registration'; - manager.getSaiSession.mockImplementation(async (webId) => { - return { - webId, - findApplicationRegistration: async (applicationId) => { - expect(applicationId).toBe(clientId); - return { iri: applicationRegistrationIri }; - } - } as AuthorizationAgent; - }); + manager.getSaiSession.mockImplementation( + async (webId) => + ({ + webId, + findApplicationRegistration: async (applicationId) => { + expect(applicationId).toBe(clientId); + return { iri: applicationRegistrationIri }; + } + }) as AuthorizationAgent + ); const request = { url: aliceAgentUrl @@ -99,15 +100,16 @@ describe('authenticated request', () => { const bobWebId = 'https://bob.example/'; const socialAgentRegistrationIri = 'https://some.example/application-registration'; - manager.getSaiSession.mockImplementation(async (webId) => { - return { - webId, - findSocialAgentRegistration: async (webid) => { - expect(webid).toBe(bobWebId); - return { iri: socialAgentRegistrationIri }; - } - } as AuthorizationAgent; - }); + manager.getSaiSession.mockImplementation( + async (webId) => + ({ + webId, + findSocialAgentRegistration: async (webid) => { + expect(webid).toBe(bobWebId); + return { iri: socialAgentRegistrationIri }; + } + }) as AuthorizationAgent + ); const request = { url: aliceAgentUrl @@ -119,7 +121,7 @@ describe('authenticated request', () => { clientId }; - const ctx = { request, authn: authn } as AuthenticatedAuthnContext; + const ctx = { request, authn } as AuthenticatedAuthnContext; agentsHandler.handle(ctx).subscribe((response) => { expect(manager.getSaiSession).toBeCalledWith(aliceWebId); diff --git a/packages/service/test/unit/handlers/api-handler-test.ts b/packages/service/test/unit/handlers/api-handler-test.ts index d896e322..bdc56069 100644 --- a/packages/service/test/unit/handlers/api-handler-test.ts +++ b/packages/service/test/unit/handlers/api-handler-test.ts @@ -15,7 +15,8 @@ import { Resource, ResponseMessageTypes, ShareAuthorizationConfirmation, - SocialAgent + SocialAgent, + SocialAgentInvitation } from '@janeirodigital/sai-api-messages'; import { ApiHandler, SaiContext } from '../../../src'; @@ -31,8 +32,12 @@ jest.mock('../../../src/services', () => ({ getDescriptions: jest.fn(), listDataInstances: jest.fn(), recordAuthorization: jest.fn(), + requestAccessUsingApplicationNeeds: jest.fn(), getResource: jest.fn(), - shareResource: jest.fn() + shareResource: jest.fn(), + getSocialAgentInvitations: jest.fn(), + createInvitation: jest.fn(), + acceptInvitation: jest.fn() })); const mocked = jest.mocked(services); @@ -319,6 +324,35 @@ describe('recordAuthorization', () => { }); }); +describe('requestAccessUsingApplicationNeeds', () => { + test('sucessful response', (done) => { + const request = { + headers, + body: { + type: RequestMessageTypes.REQUEST_AUTHORIZATION_USING_APPLICATION_NEEDS, + applicationId: 'https://projectron.example', + agentId: 'https://alice.example' + } + } as unknown as HttpHandlerRequest; + const ctx = { request, authn, saiSession, logger } as SaiContext; + + apiHandler.handle(ctx).subscribe({ + next: (response: HttpHandlerResponse) => { + expect(response.status).toBe(200); + expect(response.body.type).toBe(ResponseMessageTypes.REQUEST_ACCESS_USING_APPLICATION_NEEDS_CONFIRMTION); + expect(response.body.payload).toBe(null); + + expect(mocked.requestAccessUsingApplicationNeeds).toBeCalledWith( + request.body.applicationId, + request.body.agentId, + saiSession + ); + done(); + } + }); + }); +}); + describe('getResource', () => { test('sucessful response', (done) => { const request = { @@ -373,3 +407,75 @@ describe('shareResource', () => { }); }); }); + +describe('getSocialAgentInvitations', () => { + test('sucessful response', (done) => { + const request = { + headers, + body: { + type: RequestMessageTypes.SOCIAL_AGENT_INVITATIONS_REQUEST + } + } as unknown as HttpHandlerRequest; + const ctx = { request, authn, saiSession, logger } as SaiContext; + const socialAgentInvitations = [] as unknown as SocialAgentInvitation[]; + mocked.getSocialAgentInvitations.mockResolvedValueOnce(socialAgentInvitations); + + apiHandler.handle(ctx).subscribe({ + next: (response: HttpHandlerResponse) => { + expect(response.status).toBe(200); + expect(response.body.type).toBe(ResponseMessageTypes.SOCIAL_AGENT_INVITATIONS_RESPONSE); + expect(response.body.payload).toBe(socialAgentInvitations); + expect(mocked.getSocialAgentInvitations).toBeCalledTimes(1); + done(); + } + }); + }); +}); + +describe('createInvitation', () => { + test('sucessful response', (done) => { + const request = { + headers, + body: { + type: RequestMessageTypes.CREATE_INVITATION + } + } as unknown as HttpHandlerRequest; + const ctx = { request, authn, saiSession, logger } as SaiContext; + const socialAgentInvitation = {} as SocialAgentInvitation; + mocked.createInvitation.mockResolvedValueOnce(socialAgentInvitation); + + apiHandler.handle(ctx).subscribe({ + next: (response: HttpHandlerResponse) => { + expect(response.status).toBe(200); + expect(response.body.type).toBe(ResponseMessageTypes.INVITATION_REGISTRATION); + expect(response.body.payload).toBe(socialAgentInvitation); + expect(mocked.createInvitation).toBeCalledWith(saiSession, request.body); + done(); + } + }); + }); +}); + +describe('acceptInvitation', () => { + test('sucessful response', (done) => { + const request = { + headers, + body: { + type: RequestMessageTypes.ACCEPT_INVITATION + } + } as unknown as HttpHandlerRequest; + const ctx = { request, authn, saiSession, logger } as SaiContext; + const socialAgent = {} as SocialAgent; + mocked.acceptInvitation.mockResolvedValueOnce(socialAgent); + + apiHandler.handle(ctx).subscribe({ + next: (response: HttpHandlerResponse) => { + expect(response.status).toBe(200); + expect(response.body.type).toBe(ResponseMessageTypes.SOCIAL_AGENT_RESPONSE); + expect(response.body.payload).toBe(socialAgent); + expect(mocked.acceptInvitation).toBeCalledWith(saiSession, request.body); + done(); + } + }); + }); +}); diff --git a/packages/service/test/unit/handlers/invitations-handler-test.ts b/packages/service/test/unit/handlers/invitations-handler-test.ts new file mode 100644 index 00000000..77c5f727 --- /dev/null +++ b/packages/service/test/unit/handlers/invitations-handler-test.ts @@ -0,0 +1,133 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { jest } from '@jest/globals'; + +import { InMemoryStorage } from '@inrupt/solid-client-authn-node'; +import { type HttpError, type HttpHandlerRequest } from '@digita-ai/handlersjs-http'; +import type { AuthorizationAgent } from '@janeirodigital/interop-authorization-agent'; +import type { + CRUDAgentRegistry, + CRUDRegistrySet, + CRUDSocialAgentInvitation, + CRUDSocialAgentRegistration +} from '@janeirodigital/interop-data-model'; + +import { MockedQueue } from '../mocked-queue'; +import { + InvitationsHandler, + SessionManager, + AuthenticatedAuthnContext, + invitationCapabilityUrl, + encodeWebId +} from '../../../src'; + +jest.mock('../../../src/session-manager', () => { + const originalModule = jest.requireActual('../../../src/session-manager') as object; + + return { + ...originalModule, + SessionManager: jest.fn(() => ({ + getSaiSession: jest.fn() + })) + }; +}); + +let reciprocalQueue: MockedQueue; + +const aliceWebId = 'https://alice.example'; +const bobWebId = 'https://bob.example'; +const capabilityUrl = invitationCapabilityUrl(aliceWebId, crypto.randomUUID()); +const label = 'Bob'; +const note = 'some dude'; + +const manager = jest.mocked(new SessionManager(new InMemoryStorage())); +let invitationsHandler: InvitationsHandler; + +beforeEach(() => { + manager.getSaiSession.mockReset(); + reciprocalQueue = new MockedQueue('reciprocal-registrations'); + invitationsHandler = new InvitationsHandler(manager, reciprocalQueue); +}); + +describe('capability url request', () => { + const request = { + headers: {}, + parameters: { + encodedWebId: encodeWebId(aliceWebId) + }, + url: capabilityUrl + } as unknown as HttpHandlerRequest; + const authn = { + authenticated: true, + webId: bobWebId + }; + const ctx = { request, authn } as AuthenticatedAuthnContext; + + test('should respond with WebID', (done) => { + const addSocialAgentRegistrationMock = jest.fn( + () => + ({ + registeredAgent: bobWebId + }) as CRUDSocialAgentRegistration + ); + manager.getSaiSession.mockResolvedValueOnce({ + findSocialAgentInvitation: async () => + ({ + label, + note, + update: jest.fn() + }) as unknown as CRUDSocialAgentInvitation, + findSocialAgentRegistration: jest.fn(), + registrySet: { + hasAgentRegistry: { + addSocialAgentRegistration: addSocialAgentRegistrationMock + } as unknown as CRUDAgentRegistry + } as CRUDRegistrySet + } as unknown as AuthorizationAgent); + + invitationsHandler.handle(ctx).subscribe((response) => { + expect(response.body).toContain(aliceWebId); + expect(addSocialAgentRegistrationMock).toBeCalledWith(bobWebId, label, note); + expect(reciprocalQueue.add).toBeCalledWith( + { + webId: aliceWebId, + registeredAgent: bobWebId + }, + { delay: 10000 } + ); + done(); + }); + }); + + test('should work if registration already exists', (done) => { + manager.getSaiSession.mockResolvedValueOnce({ + findSocialAgentInvitation: async () => + ({ + update: jest.fn() + }) as unknown as CRUDSocialAgentInvitation, + findSocialAgentRegistration: async () => + ({ + registeredAgent: bobWebId + }) as CRUDSocialAgentRegistration + } as unknown as AuthorizationAgent); + + invitationsHandler.handle(ctx).subscribe((response) => { + expect(response.body).toContain(aliceWebId); + expect(reciprocalQueue.add).not.toBeCalled(); + done(); + }); + }); + + test('should throw if invitation not found', (done) => { + manager.getSaiSession.mockResolvedValueOnce({ + findSocialAgentInvitation: jest.fn() + } as unknown as AuthorizationAgent); + + invitationsHandler.handle(ctx).subscribe({ + error: (e: HttpError) => { + expect(e).toBeInstanceOf(Error); + expect(reciprocalQueue.add).not.toBeCalled(); + done(); + } + }); + }); +}); diff --git a/packages/service/test/unit/services/access-request-test.ts b/packages/service/test/unit/services/access-request-test.ts new file mode 100644 index 00000000..b18fec56 --- /dev/null +++ b/packages/service/test/unit/services/access-request-test.ts @@ -0,0 +1,30 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { jest } from '@jest/globals'; +import { type AuthorizationAgent } from '@janeirodigital/interop-authorization-agent'; +import { type CRUDSocialAgentRegistration } from '@janeirodigital/interop-data-model'; + +import { requestAccessUsingApplicationNeeds } from '../../../src/services'; + +const applicationIri = 'https://projectron.example'; +const accessNeedGroupIri = 'https://projectron.example/needs'; +const webId = 'https://bob.example'; +const socialAgentRegistration = { + setAccessNeedGroup: jest.fn() +} as unknown as CRUDSocialAgentRegistration; + +const saiSession = { + findSocialAgentRegistration: jest.fn(async () => socialAgentRegistration), + factory: { + readable: { + clientIdDocument: jest.fn(async () => ({ hasAccessNeedGroup: accessNeedGroupIri })) + } + } +} as unknown as AuthorizationAgent; + +test('sets access need group using one from the app', async () => { + await requestAccessUsingApplicationNeeds(applicationIri, webId, saiSession); + + expect(saiSession.findSocialAgentRegistration).toBeCalledWith(webId); + expect(saiSession.factory.readable.clientIdDocument).toBeCalledWith(applicationIri); + expect(socialAgentRegistration.setAccessNeedGroup).toBeCalledWith(accessNeedGroupIri); +}); diff --git a/packages/service/test/unit/services/data-registries-test.ts b/packages/service/test/unit/services/data-registries-test.ts index d85ea33b..64ab2b2a 100644 --- a/packages/service/test/unit/services/data-registries-test.ts +++ b/packages/service/test/unit/services/data-registries-test.ts @@ -1,12 +1,18 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { jest } from '@jest/globals'; +import { type Mocked } from 'jest-mock'; import { AuthorizationAgent } from '@janeirodigital/interop-authorization-agent'; +import { type CRUDSocialAgentRegistration } from '@janeirodigital/interop-data-model'; import { getDataRegistries } from '../../../src/services/data-registries'; -const webId = 'https://alice.example'; +const aliceId = 'https://alice.example'; +const bobId = 'https://bob.example'; + +const projectsTree = 'https://solidshapes.example/trees/Project'; +const tasksTree = 'https://solidshapes.example/trees/Task'; const saiSession = { - webId, + webId: aliceId, factory: { readable: { shapeTree: jest.fn((iri: string) => { @@ -24,12 +30,12 @@ const saiSession = { registrations: [ { iri: 'https://rnd.acme.example/data/projects/', - registeredShapeTree: 'https://solidshapes.example/trees/Project', + registeredShapeTree: projectsTree, contains: ['a', 'b'] }, { iri: 'https://rnd.acme.example/data/tasks/', - registeredShapeTree: 'https://solidshapes.example/trees/Task', + registeredShapeTree: tasksTree, contains: [1, 2, 3] } ] @@ -39,60 +45,146 @@ const saiSession = { registrations: [ { iri: 'https://hr.acme.example/data/projects/', - registeredShapeTree: 'https://solidshapes.example/trees/Project', + registeredShapeTree: projectsTree, contains: ['c', 'd'] }, { iri: 'https://hr.acme.example/data/tasks/', - registeredShapeTree: 'https://solidshapes.example/trees/Task', + registeredShapeTree: tasksTree, contains: [5, 6, 7, 8, 9] } ] } ] - } -} as unknown as AuthorizationAgent; + }, + findSocialAgentRegistration: jest.fn() +} as unknown as Mocked; -test('gets well formated data registries', async () => { - const result = await getDataRegistries(webId, 'en', saiSession); - expect(result).toEqual([ - { - id: 'https://rnd.acme.example/data/', - registrations: [ - { - id: 'https://rnd.acme.example/data/projects/', - shapeTree: 'https://solidshapes.example/trees/Project', - dataRegistry: 'https://rnd.acme.example/data/', - count: 2, - label: 'Projects' - }, - { - id: 'https://rnd.acme.example/data/tasks/', - shapeTree: 'https://solidshapes.example/trees/Task', - dataRegistry: 'https://rnd.acme.example/data/', - count: 3, - label: 'Tasks' - } - ] - }, - { - id: 'https://hr.acme.example/data/', - registrations: [ - { - id: 'https://hr.acme.example/data/projects/', - shapeTree: 'https://solidshapes.example/trees/Project', - dataRegistry: 'https://hr.acme.example/data/', - count: 2, - label: 'Projects' - }, - { - id: 'https://hr.acme.example/data/tasks/', - shapeTree: 'https://solidshapes.example/trees/Task', - dataRegistry: 'https://hr.acme.example/data/', - count: 5, - label: 'Tasks' +describe('owned data', () => { + test('gets well formated data registries', async () => { + const result = await getDataRegistries(aliceId, 'en', saiSession); + expect(result).toEqual([ + { + id: 'https://rnd.acme.example/data/', + registrations: [ + { + id: 'https://rnd.acme.example/data/projects/', + shapeTree: projectsTree, + dataRegistry: 'https://rnd.acme.example/data/', + count: 2, + label: 'Projects' + }, + { + id: 'https://rnd.acme.example/data/tasks/', + shapeTree: tasksTree, + dataRegistry: 'https://rnd.acme.example/data/', + count: 3, + label: 'Tasks' + } + ] + }, + { + id: 'https://hr.acme.example/data/', + registrations: [ + { + id: 'https://hr.acme.example/data/projects/', + shapeTree: projectsTree, + dataRegistry: 'https://hr.acme.example/data/', + count: 2, + label: 'Projects' + }, + { + id: 'https://hr.acme.example/data/tasks/', + shapeTree: tasksTree, + dataRegistry: 'https://hr.acme.example/data/', + count: 5, + label: 'Tasks' + } + ] + } + ]); + }); +}); + +describe('peer data', () => { + test.skip('throw if peer registration not found', async () => { + expect(getDataRegistries(bobId, 'en', saiSession)).toThrow('missing social agent registration'); + }); + + test('throw if reciprocal registration not found', async () => { + saiSession.findSocialAgentRegistration.mockResolvedValueOnce({} as CRUDSocialAgentRegistration); + expect(getDataRegistries(bobId, 'en', saiSession)).rejects.toThrow('missing social agent registration'); + }); + + test('returns empty array if no access grant', async () => { + saiSession.findSocialAgentRegistration.mockResolvedValueOnce({ + reciprocalRegistration: {} as CRUDSocialAgentRegistration + } as CRUDSocialAgentRegistration); + const result = await getDataRegistries(bobId, 'en', saiSession); + expect(result).toEqual([]); + }); + + test('gets well formated data registries', async () => { + const proRegistryIri = 'https://pro.bob.example'; + const homeRegistryIri = 'https://home.bob.example'; + saiSession.findSocialAgentRegistration.mockResolvedValueOnce({ + reciprocalRegistration: { + accessGrant: { + hasDataGrant: [ + { + dataRegistryIri: proRegistryIri, + storageIri: proRegistryIri, + hasDataRegistration: 'https://pro.bob.example/projects', + registeredShapeTree: projectsTree + }, + { + dataRegistryIri: proRegistryIri, + storageIri: proRegistryIri, + hasDataRegistration: 'https://pro.bob.example/tasks', + registeredShapeTree: tasksTree + }, + { + dataRegistryIri: homeRegistryIri, + storageIri: homeRegistryIri, + hasDataRegistration: 'https://home.bob.example/projects', + registeredShapeTree: projectsTree + } + ] } - ] - } - ]); + } as unknown as CRUDSocialAgentRegistration + } as CRUDSocialAgentRegistration); + const result = await getDataRegistries(bobId, 'en', saiSession); + expect(result).toEqual([ + { + id: proRegistryIri, + label: proRegistryIri, + registrations: [ + { + id: 'https://pro.bob.example/projects', + shapeTree: projectsTree, + dataRegistry: proRegistryIri, + label: 'Projects' + }, + { + id: 'https://pro.bob.example/tasks', + shapeTree: tasksTree, + dataRegistry: proRegistryIri, + label: 'Tasks' + } + ] + }, + { + id: homeRegistryIri, + label: homeRegistryIri, + registrations: [ + { + id: 'https://home.bob.example/projects', + shapeTree: projectsTree, + dataRegistry: homeRegistryIri, + label: 'Projects' + } + ] + } + ]); + }); }); diff --git a/packages/service/test/unit/services/inivitations-test.ts b/packages/service/test/unit/services/inivitations-test.ts new file mode 100644 index 00000000..0feb15ca --- /dev/null +++ b/packages/service/test/unit/services/inivitations-test.ts @@ -0,0 +1,170 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { jest } from '@jest/globals'; +import { type Mocked } from 'jest-mock'; +import { Invitation } from '@janeirodigital/sai-api-messages'; +import { type AuthorizationAgent } from '@janeirodigital/interop-authorization-agent'; +import type { CRUDSocialAgentInvitation, CRUDSocialAgentRegistration } from '@janeirodigital/interop-data-model'; +import { encodeWebId } from '../../../src'; +import { getSocialAgentInvitations, createInvitation, acceptInvitation } from '../../../src/services'; + +const webId = 'https://bob.example'; +const aliceId = 'https://alice.example'; + +describe('getSocialAgentInvitations', () => { + const saiSession = { + socialAgentInvitations: { + async *[Symbol.asyncIterator]() { + yield { + iri: 'https://alice.example/invitations/yori', + capabilityUrl: 'https://auth.alice.example/invitation/secret-yori', + label: 'Yori', + note: 'Cage fighter' + } as CRUDSocialAgentInvitation; + yield { + iri: 'https://alice.example/invitations/sam', + capabilityUrl: 'https://auth.alice.example/invitation/secret-sam', + label: 'Sam', + note: 'Juggler' + } as CRUDSocialAgentInvitation; + } + } + } as unknown as Mocked; + test('gets formated inviatations', async () => { + const result = await getSocialAgentInvitations(saiSession); + expect(result).toEqual([ + { + id: 'https://alice.example/invitations/yori', + capabilityUrl: 'https://auth.alice.example/invitation/secret-yori', + label: 'Yori', + note: 'Cage fighter' + }, + { + id: 'https://alice.example/invitations/sam', + capabilityUrl: 'https://auth.alice.example/invitation/secret-sam', + label: 'Sam', + note: 'Juggler' + } + ]); + }); +}); + +describe('createInvitation', () => { + const saiSession = { + webId, + registrySet: { + hasAgentRegistry: { + addSocialAgentInvitation: jest.fn() + } + } + } as unknown as Mocked; + + test('creates invitation', async () => { + const invitationBase = { + label: 'Yori', + note: 'Cage fighter' + }; + const invitation = { + iri: 'https://alice.example/invitations/yori', + capabilityUrl: 'https://auth.alice/example/invitation/secret-yori', + ...invitationBase + } as CRUDSocialAgentInvitation; + saiSession.registrySet.hasAgentRegistry.addSocialAgentInvitation.mockResolvedValueOnce(invitation); + const result = await createInvitation(saiSession, invitationBase); + expect(saiSession.registrySet.hasAgentRegistry.addSocialAgentInvitation).toBeCalledWith( + expect.stringContaining(encodeWebId(webId)), + invitationBase.label, + invitationBase.note + ); + expect(result).toEqual({ + id: 'https://alice.example/invitations/yori', + capabilityUrl: 'https://auth.alice/example/invitation/secret-yori', + ...invitationBase + }); + }); +}); + +describe('acceptInvitation', () => { + const saiSession = { + fetch: { + raw: jest.fn() + }, + findSocialAgentRegistration: jest.fn(), + registrySet: { + hasAgentRegistry: { + addSocialAgentRegistration: jest.fn() + } + } + } as unknown as Mocked; + const invitation = { + capabilityUrl: 'https://auth.alice/example/invitation/secret-yori', + label: 'Alice', + note: 'Manager' + } as Invitation; + + test('throws if fetch failed', async () => { + saiSession.fetch.raw.mockResolvedValue({ ok: false } as Response); + expect(acceptInvitation(saiSession, invitation)).rejects.toThrow('failed'); + }); + + test('throws if did not get webid', async () => { + saiSession.fetch.raw.mockResolvedValue({ + ok: true, + text: () => '' + } as unknown as Response); + expect(acceptInvitation(saiSession, invitation)).rejects.toThrow('can not accept'); + }); + + test('does nothing if already exists', async () => { + saiSession.fetch.raw.mockResolvedValue({ + ok: true, + text: () => aliceId + } as unknown as Response); + const socialAgentRegistration = { + registeredAgent: aliceId, + label: 'Alice', + registeredAt: new Date('2023-12-27T19:30:01.822Z'), + reciprocalRegistration: 'https://alice.example/agents/bob', + discoverAndUpdateReciprocal: jest.fn() + } as unknown as CRUDSocialAgentRegistration; + saiSession.findSocialAgentRegistration.mockResolvedValueOnce(socialAgentRegistration); + const result = await acceptInvitation(saiSession, invitation); + expect(saiSession.registrySet.hasAgentRegistry.addSocialAgentRegistration).not.toBeCalled(); + expect(socialAgentRegistration.discoverAndUpdateReciprocal).not.toBeCalled(); + expect(result).toEqual( + expect.objectContaining({ + id: socialAgentRegistration.registeredAgent, + label: socialAgentRegistration.label, + authorizationDate: socialAgentRegistration.registeredAt!.toISOString() + }) + ); + }); + + test('accepts invitation', async () => { + saiSession.fetch.raw.mockResolvedValue({ + ok: true, + text: () => aliceId + } as unknown as Response); + const socialAgentRegistration = { + registeredAgent: aliceId, + label: 'Alice', + registeredAt: new Date('2023-12-27T19:30:01.822Z'), + discoverAndUpdateReciprocal: jest.fn() + } as unknown as CRUDSocialAgentRegistration; + saiSession.findSocialAgentRegistration.mockResolvedValueOnce(undefined); + saiSession.registrySet.hasAgentRegistry.addSocialAgentRegistration.mockResolvedValueOnce(socialAgentRegistration); + const result = await acceptInvitation(saiSession, invitation); + expect(saiSession.registrySet.hasAgentRegistry.addSocialAgentRegistration).toBeCalledWith( + aliceId, + invitation.label, + invitation.note + ); + expect(socialAgentRegistration.discoverAndUpdateReciprocal).toBeCalledWith(saiSession.fetch.raw); + expect(result).toEqual( + expect.objectContaining({ + id: socialAgentRegistration.registeredAgent, + label: socialAgentRegistration.label, + authorizationDate: socialAgentRegistration.registeredAt!.toISOString() + }) + ); + }); +});