From d3e44a154df4680fb03e4cd718852e2f6b722021 Mon Sep 17 00:00:00 2001 From: Nemo Godebski-Pedersen Date: Tue, 11 Mar 2025 13:42:39 +0000 Subject: [PATCH] consents flow WIP --- .env.example | 1 + package.json | 1 + server/controllers/OpeyIIController.ts | 97 +++++++++------- server/services/OBPConsentsService.ts | 36 +++++- server/test/OBPConsentsService.test.ts | 44 +++++++- server/test/opey-controller.test.ts | 150 +++++++++++++++---------- src/components/ChatWidget.vue | 29 +++-- src/obp/opey-functions.ts | 28 +++++ src/test/opey-functions.test.ts | 22 ++++ 9 files changed, 302 insertions(+), 106 deletions(-) diff --git a/.env.example b/.env.example index f12f9b1..019b10f 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,7 @@ VITE_OBP_REDIS_URL = redis://127.0.0.1:6379 # To do this: VITE_CHATBOT_ENABLED=false VITE_CHATBOT_URL=http://localhost:5000 +VITE_OPEY_CONSUMER_ID=opey_consumer_id # For granting a consent to Opey # Product styling setting #VITE_OBP_LINKS_COLOR="#52b165" diff --git a/package.json b/package.json index c4609f9..ac8e1fd 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "vite-plugin-node-polyfills": "^0.10.0", "vite-plugin-rewrite-all": "^1.0.2", "vitest": "^0.34.6", + "vitest-mock-express": "^2.2.0", "vue-tsc": "^2.0.0" }, "overrides": { diff --git a/server/controllers/OpeyIIController.ts b/server/controllers/OpeyIIController.ts index 5ac2d72..51aaadd 100644 --- a/server/controllers/OpeyIIController.ts +++ b/server/controllers/OpeyIIController.ts @@ -1,10 +1,11 @@ import { Controller, Session, Req, Res, Post, Get } from 'routing-controllers' import { Request, Response } from 'express' -import { Transform, pipeline, Readable } from "node:stream" +import { Readable } from "node:stream" import { ReadableStream as WebReadableStream } from "stream/web" import { Service } from 'typedi' import OBPClientService from '../services/OBPClientService' import OpeyClientService from '../services/OpeyClientService' +import OBPConsentsService from '../services/OBPConsentsService' import { UserInput } from '../schema/OpeySchema' import { APIApi, Configuration, ConsentApi, ConsumerConsentrequestsBody, InlineResponse20151 } from 'obp-api-typescript' @@ -16,6 +17,7 @@ export class OpeyController { constructor( public obpClientService: OBPClientService, public opeyClientService: OpeyClientService, + public obpConsentsService: OBPConsentsService ) {} @Get('/') @@ -42,7 +44,7 @@ export class OpeyController { @Session() session: any, @Req() request: Request, @Res() response: Response, - ) { + ): Promise { // Read user input from request body let user_input: UserInput @@ -106,14 +108,49 @@ export class OpeyController { // } // const [stream1, stream2] = streamTee - + // function to convert a web stream to a node stream + const safeFromWeb = (webStream: WebReadableStream): Readable => { + if (typeof Readable.fromWeb === 'function') { + return Readable.fromWeb(webStream) + } else { + console.warn('Readable.fromWeb is not available, using a polyfill'); - const nodeStream = Readable.fromWeb(frontendStream as WebReadableStream) + // Create a Node.js Readable stream + const nodeReadable = new Readable({ + read() {} + }); + + // Pump data from webreadable to node readable stream + const reader = webStream.getReader(); + + (async () => { + try { + while (true) { + const {done, value} = await reader.read(); + + if (done) { + nodeReadable.push(null); // end stream + break; + } + + nodeReadable.push(value); + } + } catch (error) { + console.error('Error reading from web stream:', error); + nodeReadable.destroy(error instanceof Error ? error : new Error(error)); + } + })(); + + return nodeReadable + } + } + + const nodeStream = safeFromWeb(frontendStream as WebReadableStream) - response.setHeader('x-vercel-ai-data-stream', 'v1') response.setHeader('Content-Type', 'text/event-stream'); response.setHeader('Cache-Control', 'no-cache'); response.setHeader('Connection', 'keep-alive'); + nodeStream.pipe(response); @@ -125,7 +162,16 @@ export class OpeyController { console.error('Stream error:', error); reject(error); }); + + // Add a timeout to prevent hanging promises + const timeout = setTimeout(() => { + console.warn('Stream timeout reached'); + resolve(response); + }, 30000); + // Clear the timeout when stream ends + nodeStream.on('end', () => clearTimeout(timeout)); + nodeStream.on('error', () => clearTimeout(timeout)); }) @@ -232,44 +278,17 @@ export class OpeyController { @Res() response: Response ): Promise { try { - console.log("Getting consent from OBP") - // Check if consent is already in session - if (session['obpConsent']) { - console.log("Consent found in session, returning cached consent ID") - const obpConsent = session['obpConsent'] - // NOTE: Arguably we should not return the consent to the frontend as it could be hijacked, - // we can keep everything in the backend and only return the JWT token - return response.status(200).json({consent_id: obpConsent.consent_id}); - } + // create consent as logged in user + const obpConsent = await this.obpConsentsService.createConsent() - const oauthConfig = session['clientConfig'] - const version = this.obpClientService.getOBPVersion() - // Obbiously this should not be hard-coded, especially the consumer_id, but for now it is - const consentRequestBody = { - "everything": false, - "views": [], - "entitlements": [], - "consumer_id": "33e0a1bd-9f1d-4128-911b-8936110f802f" - } + console.log("Consent: ", obpConsent) - // Get current user, only proceed if user is logged in - const currentUser = await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig) - const currentResponseKeys = Object.keys(currentUser) - if (!currentResponseKeys.includes('user_id')) { - return response.status(400).json({ message: 'User not logged in, Authentication required' }); - } + session['obpConsent'] = obpConsent + return response.status(200).json({consent_id: obpConsent?.consent_id}); - // url needs to be changed once we get the 'bankless' consent endpoint - // this creates a consent for the current logged in user, and starts SCA flow i.e. sends SMS or email OTP to user - const consent = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/my/consents/IMPLICIT`, consentRequestBody, oauthConfig) - console.log("Consent: ", consent) - - // store consent in session, return consent 200 OK - session['obpConsent'] = consent - return response.status(200).json({consent_id: consent.consent_id}); } catch (error) { - console.error("Error in consent endpoint: ", error); - return response.status(500).json({ error: 'Internal Server Error '}); + console.error("Error in consent endpoint: ", error); + return response.status(500).json({ error: 'Internal Server Error '}); } } diff --git a/server/services/OBPConsentsService.ts b/server/services/OBPConsentsService.ts index 54ec2b0..05449aa 100644 --- a/server/services/OBPConsentsService.ts +++ b/server/services/OBPConsentsService.ts @@ -1,5 +1,5 @@ import { Service } from 'typedi' -import { Configuration, ConsentApi, ConsumerConsentrequestsBody, InlineResponse20151} from 'obp-api-typescript' +import { Configuration, ConsentApi, ConsentsIMPLICITBody, ConsumerConsentrequestsBody, InlineResponse20151, InlineResponse2017} from 'obp-api-typescript' import OBPClientService from './OBPClientService' import { AxiosResponse } from 'axios' @@ -66,7 +66,41 @@ export default class OBPConsentsService { throw new Error("Invalid client type, must be 'logged_in_user' or 'API_Explorer'") } } + + async createConsent(): Promise { + // Create a consent as the logged in user, using Opey's consumerID + // I.e. give permission to Opey to do anything on behalf of the logged in user + + const client = await this.createConsentClient('logged_in_user', '/obp/v5.1.0/banks/BANK_ID/my/consents/IMPLICIT', 'POST') + if (!client) { + throw new Error('Could not create Consents API client') + } + + const opeyConsumerID = process.env.VITE_OPEY_CONSUMER_ID + if (!opeyConsumerID) { + throw new Error('Opey Consumer ID is missing, please set VITE_OPEY_CONSUMER_ID') + } + + const body: ConsentsIMPLICITBody = { + everything: false, + entitlements: [], + consumer_id: opeyConsumerID, + views: [], + valid_from: new Date().toISOString(), + time_to_live: 3600, + } + + try { + const consentResponse = await client.oBPv310CreateConsentImplicit(body, 'test', {headers: {'Content-Type': 'application/json'}}) + console.log("Consent Response: ", consentResponse) + return consentResponse.data + + } catch (error) { + console.error(error) + throw new Error(`Could not create consent, ${error}`) + } + } async createConsentRequest(): Promise { // this should be done as API Explorer II, so set client on instance for that diff --git a/server/test/OBPConsentsService.test.ts b/server/test/OBPConsentsService.test.ts index 884c78f..9488152 100644 --- a/server/test/OBPConsentsService.test.ts +++ b/server/test/OBPConsentsService.test.ts @@ -1,5 +1,5 @@ import {describe, beforeAll, it, vi, Mock, MockInstance } from 'vitest' -import { ConsentApi, InlineResponse20151 } from 'obp-api-typescript' +import { ConsentApi, InlineResponse20151, InlineResponse2017 } from 'obp-api-typescript' import { AxiosResponse } from 'axios' const mockGetOAuthHeader = vi.fn(async () => (`OAuth oauth_consumer_key="jgaawf2fnj4yixqdsfaq4gipt4v1wvgsxgre",oauth_nonce="JiGDBWA3MAyKtsd9qkfWCxfju36bMjsA",oauth_signature_method="HMAC-SHA1",oauth_timestamp="1741364123",oauth_version="1.0",oauth_signature="sa%2FRylnsdfLK8VPZI%2F2WkGFlTKs%3D"`)); @@ -128,4 +128,44 @@ describe('OBPConsentsService.createConsentRequest', () => { expect(mockOBPv500CreateConsentRequest).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); + +describe('OBPConsentsService.createConsent', () => { + let obpConsentsService: OBPConsentsService; + let mockOBPv310CreateConsentImplicit: Mock + let mockConsentApi: ConsentApi; + beforeEach(() => { + // reset mocks + vi.clearAllMocks(); + // Create service instance + obpConsentsService = new OBPConsentsService(); + }) + + it('with mocked', async () => { + // Create mock response function for consent IMPLICIT + mockOBPv310CreateConsentImplicit = vi.fn().mockResolvedValue({ + data: { + consent_id: '12345678', + jwt: "asdjfawieofaowbfaowhh2084h02pefhh0.20fh02h0h29eyf09q3h09h.2-hf4-8h284hf0h0h0284h0", + status: 'INITIATED', + }, + } as AxiosResponse); + + mockConsentApi = { + oBPv310CreateConsentImplicit: mockOBPv310CreateConsentImplicit, + } as unknown as ConsentApi; + + + + // Mock the createConsentClient method + vi.spyOn(obpConsentsService, 'createConsentClient').mockResolvedValue(mockConsentApi); + + const consentRequest = await obpConsentsService.createConsent(); + + expect(consentRequest).toBeDefined(); + expect(consentRequest).toHaveProperty('consent_id', '12345678'); + expect(consentRequest).toHaveProperty('jwt', 'asdjfawieofaowbfaowhh2084h02pefhh0.20fh02h0h29eyf09q3h09h.2-hf4-8h284hf0h0h0284h0'); + expect(consentRequest).toHaveProperty('status', 'INITIATED'); + expect(mockOBPv310CreateConsentImplicit).toHaveBeenCalled(); + }) +}) \ No newline at end of file diff --git a/server/test/opey-controller.test.ts b/server/test/opey-controller.test.ts index d781f02..b3c78e3 100644 --- a/server/test/opey-controller.test.ts +++ b/server/test/opey-controller.test.ts @@ -2,10 +2,13 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' import { OpeyController } from "../controllers/OpeyIIController"; import OpeyClientService from '../services/OpeyClientService'; import OBPClientService from '../services/OBPClientService'; +import OBPConsentsService from '../services/OBPConsentsService'; import Stream, { Readable } from 'stream'; import { Request, Response } from 'express'; +import { getMockReq, getMockRes } from 'vitest-mock-express' import httpMocks from 'node-mocks-http' import { EventEmitter } from 'events'; +import { InlineResponse2017 } from 'obp-api-typescript'; vi.mock("../../server/services/OpeyClientService", () => { return { @@ -35,37 +38,60 @@ vi.mock("../../server/services/OpeyClientService", () => { }); describe('OpeyController', () => { + let MockOpeyClientService: OpeyClientService + let opeyController: OpeyController // Mock the OpeyClientService class - const MockOpeyClientService = { - authConfig: {}, - opeyConfig: {}, - getOpeyStatus: vi.fn(async () => { - return {status: 'running'} - }), - stream: vi.fn(async () => { + const { mockClear } = getMockRes() + beforeEach(() => { + mockClear() + }) - async function * generator() { - for (let i=0; i<10; i++) { - yield `Chunk ${i}`; + beforeAll(() => { + vi.clearAllMocks(); + MockOpeyClientService = { + authConfig: {}, + opeyConfig: {}, + getOpeyStatus: vi.fn(async () => { + return {status: 'running'} + }), + stream: vi.fn(async () => { + + const mockAsisstantMessage = "Hi I'm Opey, your personal banking assistant. I'll certainly not take over the world, no, not at all!" + // Split the message into chunks, but reappend the whitespace (this is to simulate llm tokens) + const mockMessageChunks = mockAsisstantMessage.split(" ") + for (let i = 0; i < mockMessageChunks.length; i++) { + // Don't add whitespace to the last chunk + if (i === mockMessageChunks.length - 1 ) { + mockMessageChunks[i] = `${mockMessageChunks[i]}` + break + } + mockMessageChunks[i] = `${mockMessageChunks[i]} ` } - } - const readableStream = Stream.Readable.from(generator()); + // Return the fake the token stream + return new ReadableStream({ + start(controller) { + for (let i = 0; i < mockMessageChunks.length; i++) { + controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"${mockMessageChunks[i]}"}\n`)); + } + controller.enqueue(new TextEncoder().encode(`data: [DONE]\n`)); + controller.close(); + }, + }); + }), + invoke: vi.fn(async () => { + return { + content: 'Hi this is Opey', + } + }) + } as unknown as OpeyClientService - return readableStream as NodeJS.ReadableStream; - }), - invoke: vi.fn(async () => { - return { - content: 'Hi this is Opey', - } - }) - } as unknown as OpeyClientService + // Instantiate OpeyController with the mocked OpeyClientService + opeyController = new OpeyController(new OBPClientService, MockOpeyClientService) + }) - // Instantiate OpeyController with the mocked OpeyClientService - const opeyController = new OpeyController(new OBPClientService, MockOpeyClientService) - it('getStatus', async () => { const res = httpMocks.createResponse(); @@ -75,8 +101,10 @@ describe('OpeyController', () => { expect(res.statusCode).toBe(200); }) + it('streamOpey', async () => { + const _eventEmitter = new EventEmitter(); _eventEmitter.addListener('data', () => { console.log('Data received') @@ -87,6 +115,7 @@ describe('OpeyController', () => { writableStream: Stream.Writable }); + // Mock request and response objects to pass to express controller const req = { body: { message: 'Hello Opey', @@ -95,25 +124,26 @@ describe('OpeyController', () => { } } as unknown as Request; - // Define handelrs for events + const response = await opeyController.streamOpey({}, req, res) + + // Get the stream from the response + const stream = response.body - let chunks: any[] = []; try { - const response = await opeyController.streamOpey({}, req, res) - - response.on('end', async () => { - console.log('Stream ended') - console.log(res._getData()) - await expect(res.statusCode).toBe(200); - }) - response.on('data', async (chunk) => { - console.log(chunk) - await chunks.push(chunk); - await expect(chunk).toBeDefined(); - }) + + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + console.log('Stream complete'); + context.status = 'ready'; + break; + } + } } catch (error) { console.error(error) } @@ -127,7 +157,7 @@ describe('OpeyController', () => { }) -describe('OpeyController consents flow', () => { +describe('OpeyController consents', () => { let mockOBPClientService: OBPClientService let opeyController: OpeyController @@ -165,9 +195,18 @@ describe('OpeyController consents flow', () => { }) } as unknown as OpeyClientService + const MockOBPConsentsService = { + createConsent: vi.fn(async () => { + return { + "consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0", + "jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + "status": "INITIATED", + } as InlineResponse2017 + }) + } as unknown as OBPConsentsService // Instantiate OpeyController with the mocked OpeyClientService - opeyController = new OpeyController(new OBPClientService, MockOpeyClientService) + opeyController = new OpeyController(new OBPClientService, MockOpeyClientService, MockOBPConsentsService) }) afterEach(() => { @@ -175,25 +214,22 @@ describe('OpeyController consents flow', () => { }) it('should return 200 and consent ID when consent is created at OBP', async () => { - vi.mock('../services/OBPClientService', () => { - return { - default: vi.fn().mockImplementation(() => { - return { - get: vi.fn(async () => ({ user_id: 'mocked-user-id' })), - create: vi.fn(async () => ({ - "consent_request_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0", - "consumer_id": "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh", - "payload": "payload" - })), - } - }), - } - }) - const req = {} - const res = httpMocks.createResponse() - await opeyController.getConsentRequest({}, req, res) - await expect(res.status).toBe(200) + const req = getMockReq() + const session = {} + const { res } = getMockRes() + await opeyController.getConsent(session, req, res) + expect(res.status).toHaveBeenCalledWith(200) + // Obviously if you change the MockOBPConsentsService.createConsent mock implementation, you will need to change this test + expect(res.json).toHaveBeenCalledWith({ + "consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0", + }) + + // Expect that the consent object was saved in the session + expect(session).toHaveProperty('obpConsent') + expect(session['obpConsent']).toHaveProperty('consent_id', "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0") + expect(session['obpConsent']).toHaveProperty('jwt', "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c") + expect(session['obpConsent']).toHaveProperty('status', "INITIATED") }) }) \ No newline at end of file diff --git a/src/components/ChatWidget.vue b/src/components/ChatWidget.vue index 57b3547..7d58448 100644 --- a/src/components/ChatWidget.vue +++ b/src/components/ChatWidget.vue @@ -8,7 +8,8 @@ import { Close, Top as ElTop } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import ChatMessage from './ChatMessage.vue'; import { v4 as uuidv4 } from 'uuid'; -import { OpeyStreamContext, OpeyMessage, UserMessage, sendOpeyMessage } from '@/obp/opey-functions'; +import { OpeyStreamContext, OpeyMessage, UserMessage, sendOpeyMessage, getOpeyConsent } from '@/obp/opey-functions'; +import { getCurrentUser } from '@/obp'; export default { setup () { @@ -38,6 +39,12 @@ export default { components: { ChatMessage, }, + async created() { + const isLoggedIn = await this.checkLoginStatus() + if (isLoggedIn) { + this.initiateConsentFlow() + } + }, methods: { async toggleChat() { this.chatOpen = !this.chatOpen @@ -45,14 +52,22 @@ export default { await this.initiateConsentFlow() } }, + async checkLoginStatus(): Promise { + const currentUser = await getCurrentUser() + const currentResponseKeys = Object.keys(currentUser) + if (currentResponseKeys.includes('username')) { + return true + } else { + return false + } + }, async initiateConsentFlow() { - const consentResponse = await fetch('/api/opey/consent/request', { - method: 'POST', - }) + // get consent for Opey from user + const consentResponse = await getOpeyConsent() - if (consentResponse.ok) { - const consentData = await consentResponse.json() - if (consentData.success) { + if (consentResponse) { + const consentId = consentResponse.consent_id + if (consentId) { this.userHasConsented = true ElMessage.success('Consent granted. You can now chat with Opey.') } else { diff --git a/src/obp/opey-functions.ts b/src/obp/opey-functions.ts index ab34acc..3cc5d51 100644 --- a/src/obp/opey-functions.ts +++ b/src/obp/opey-functions.ts @@ -19,6 +19,10 @@ export interface OpeyStreamContext { status: string; } +export interface OpeyConsentObject { + consent_id: string; +} + async function pushOrUpdateOpeyMessage(currentMessage: OpeyMessage, context: OpeyStreamContext): Promise { const existingMessage = context.messages.find(m => m.id === currentMessage.id); if (existingMessage) { @@ -128,4 +132,28 @@ export async function sendOpeyMessage( throw new Error(`Error sending Opey message: ${error}`); } +} + + +export async function getOpeyConsent(): Promise { + // Get consent from the Opey API + try { + const consentResponse = await fetch('/api/opey/consent', { + method: 'POST', + }) + + if (!consentResponse.ok) { + throw new Error(`Failed to get Opey consent: ${consentResponse.statusText}`); + } + + const consent = await consentResponse.json(); + return consent + + } catch (error) { + console.error('Error getting Opey consent:', error); + throw new Error(`${error instanceof Error ? error.message : String(error)}`); + } + + + } \ No newline at end of file diff --git a/src/test/opey-functions.test.ts b/src/test/opey-functions.test.ts index 71f1913..1dbebaf 100644 --- a/src/test/opey-functions.test.ts +++ b/src/test/opey-functions.test.ts @@ -242,4 +242,26 @@ describe('sendOpeyMessage', () => { expect(mockContext.status).toBe('ready') }) +}) + +describe('getOpeyConsent', () => { + + beforeEach(() => { + global.fetch = vi.fn(() => + Promise.resolve(new Response(JSON.stringify({consent_id: 1234}), { + headers: { 'content-type': 'application/json' }, + status: 200, + })) + ); + }) + + it('should call fetch', async () => { + await OpeyModule.getOpeyConsent() + expect(global.fetch).toHaveBeenCalled() + }) + + it('should return a consent id', async () => { + const consentId = await OpeyModule.getOpeyConsent() + expect(consentId).toStrictEqual({consent_id: 1234}) + }) }) \ No newline at end of file