From cefda904c9429b6a5a2cf4098d9da91ca101deda Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Wed, 23 Mar 2022 10:21:08 -0700 Subject: [PATCH] chore: updating Agent generic interface (#536) also adds additional comments for new methods and some additional README content. --- packages/agent/README.md | 30 ++++ packages/agent/src/actor.test.ts | 286 +++++++++++++++++-------------- packages/agent/src/agent/api.ts | 17 ++ 3 files changed, 202 insertions(+), 131 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index 57dbaea23..d7827ef42 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -33,3 +33,33 @@ import { Actor, HttpAgent } from "@dfinity/agent"; ``` const actor = require("@dfinity/agent"); ``` + +## Using an Agent + +The agent is a low-level interface that the Actor uses to encode and decode messages to the Internet Computer. It provides `call`, `query` and `readState` methods to the Actor, as well as a few additional utilities. For the most part, calls through the agent are intended to be structured through an Actor, configured with a canister interface that can be automatically generated from a [Candid](https://github.com/dfinity/candid) interface. + +## Initializing an Actor + +The most common use for the agent is to create an actor. This is done by calling the `Actor.createActor` constructor: + +``` +Actor.createActor(interfaceFactory: InterfaceFactory, configuration: ActorConfig): ActorSubclass +``` + +The `interfaceFactory` is a function that returns a runtime interface that the Actor uses to strucure calls to a canister. The interfaceFactory can be written manually, but it is recommended to use the `dfx generate` command to generate the interface for your project, or to use the `didc` tool to generate the interface for your project. + +### Inspecting an actor's agent + +Use the `Actor.agentOf` method to get the agent of an actor: + +``` +const defaultAgent = Actor.agentOf(defaultActor); +``` + +This is useful if you need to replace or invalidate the identity used by an actor's agent. + +For example, if you want to replace the identity of an actor's agent with a newly authenticated identity from [Internet Identity](https://identity.ic0.app), you can do so by calling the `Actor.replaceAgent` method: + +``` +defaultAgent.replaceIdentity(await authClient.getIdentity()); +``` diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index 5e4ebd55e..98604ab57 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -15,112 +15,145 @@ afterEach(() => { global.Date.now = originalDateNowFn; }); -test.skip('makeActor', async () => { - const actorInterface = () => { - return IDL.Service({ - greet: IDL.Func([IDL.Text], [IDL.Text]), - }); - }; - - const expectedReplyArg = IDL.encode([IDL.Text], ['Hello, World!']); - - const mockFetch: jest.Mock = jest - .fn() - .mockImplementationOnce((/*resource, init*/) => { - return Promise.resolve( - new Response(null, { - status: 202, - }), - ); - }) - .mockImplementationOnce((resource, init) => { - const body = cbor.encode({ status: 'received' }); - return Promise.resolve( - new Response(body, { - status: 200, - }), - ); - }) - .mockImplementationOnce((resource, init) => { - const body = cbor.encode({ status: 'processing' }); - return Promise.resolve( - new Response(body, { - status: 200, - }), - ); - }) - .mockImplementationOnce((resource, init) => { - const body = cbor.encode({ - status: 'replied', - reply: { - arg: expectedReplyArg, - }, +describe('makeActor', () => { + // TODO: update tests to be compatible with changes to Certificate + it.skip('should encode calls', async () => { + const actorInterface = () => { + return IDL.Service({ + greet: IDL.Func([IDL.Text], [IDL.Text]), + }); + }; + + const expectedReplyArg = IDL.encode([IDL.Text], ['Hello, World!']); + + const mockFetch: jest.Mock = jest + .fn() + .mockImplementationOnce((/*resource, init*/) => { + return Promise.resolve( + new Response(null, { + status: 202, + }), + ); + }) + .mockImplementationOnce((resource, init) => { + const body = cbor.encode({ status: 'received' }); + return Promise.resolve( + new Response(body, { + status: 200, + }), + ); + }) + .mockImplementationOnce((resource, init) => { + const body = cbor.encode({ status: 'processing' }); + return Promise.resolve( + new Response(body, { + status: 200, + }), + ); + }) + .mockImplementationOnce((resource, init) => { + const body = cbor.encode({ + status: 'replied', + reply: { + arg: expectedReplyArg, + }, + }); + return Promise.resolve( + new Response(body, { + status: 200, + }), + ); }); - return Promise.resolve( - new Response(body, { - status: 200, - }), - ); - }); - const methodName = 'greet'; - const argValue = 'Name'; + const methodName = 'greet'; + const argValue = 'Name'; - const arg = IDL.encode([IDL.Text], [argValue]); + const arg = IDL.encode([IDL.Text], [argValue]); - const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c'); - const principal = await Principal.anonymous(); - const sender = principal.toUint8Array(); + const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c'); + const principal = await Principal.anonymous(); + const sender = principal.toUint8Array(); - const nonces = [ - new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) as Nonce, - new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]) as Nonce, - new Uint8Array([2, 3, 4, 5, 6, 7, 8, 9]) as Nonce, - new Uint8Array([3, 4, 5, 6, 7, 8, 9, 0]) as Nonce, - new Uint8Array([4, 5, 6, 7, 8, 9, 0, 1]) as Nonce, - ]; + const nonces = [ + new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) as Nonce, + new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]) as Nonce, + new Uint8Array([2, 3, 4, 5, 6, 7, 8, 9]) as Nonce, + new Uint8Array([3, 4, 5, 6, 7, 8, 9, 0]) as Nonce, + new Uint8Array([4, 5, 6, 7, 8, 9, 0, 1]) as Nonce, + ]; - const expectedCallRequest = { - content: { - request_type: SubmitRequestType.Call, - canister_id: canisterId, - method_name: methodName, - arg, - nonce: nonces[0], - sender, - ingress_expiry: new Expiry(300000), - }, - } as UnSigned; + const expectedCallRequest = { + content: { + request_type: SubmitRequestType.Call, + canister_id: canisterId, + method_name: methodName, + arg, + nonce: nonces[0], + sender, + ingress_expiry: new Expiry(300000), + }, + } as UnSigned; + + const expectedCallRequestId = await requestIdOf(expectedCallRequest.content); - const expectedCallRequestId = await requestIdOf(expectedCallRequest.content); + let nonceCount = 0; - let nonceCount = 0; + const httpAgent = new HttpAgent({ fetch: mockFetch }); + httpAgent.addTransform(makeNonceTransform(() => nonces[nonceCount++])); - const httpAgent = new HttpAgent({ fetch: mockFetch }); - httpAgent.addTransform(makeNonceTransform(() => nonces[nonceCount++])); + const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); + const reply = await actor.greet(argValue); - const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); - const reply = await actor.greet(argValue); + expect(reply).toEqual(IDL.decode([IDL.Text], expectedReplyArg)[0]); - expect(reply).toEqual(IDL.decode([IDL.Text], expectedReplyArg)[0]); + const { calls } = mockFetch.mock; - const { calls } = mockFetch.mock; + expect(calls.length).toBe(5); + expect(calls[0]).toEqual([ + `http://localhost/api/v2/canister/${canisterId.toText()}/call`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/cbor', + }, + body: cbor.encode(expectedCallRequest), + }, + ]); + + expect(calls[1]).toEqual([ + `http://localhost/api/v2/canister/${canisterId.toText()}/read_state`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/cbor', + }, + body: cbor.encode({ + content: { + request_type: 'request_status', + request_id: expectedCallRequestId, + ingress_expiry: new Expiry(300000), + }, + }), + }, + ]); - expect(calls.length).toBe(5); - expect(calls[0]).toEqual([ - `http://localhost/api/v2/canister/${canisterId.toText()}/call`, - { + expect(calls[2][0]).toBe('http://localhost/api/v1/read'); + expect(calls[2][1]).toEqual({ method: 'POST', headers: { 'Content-Type': 'application/cbor', }, - body: cbor.encode(expectedCallRequest), - }, - ]); + body: cbor.encode({ + content: { + request_type: 'request_status', + request_id: expectedCallRequestId, + ingress_expiry: new Expiry(300000), + }, + }), + }); - expect(calls[1]).toEqual([ - `http://localhost/api/v2/canister/${canisterId.toText()}/read_state`, - { + expect(calls[3][0]).toBe('http://localhost/api/v1/read'); + expect(calls[3][1]).toEqual({ method: 'POST', headers: { 'Content-Type': 'application/cbor', @@ -132,52 +165,43 @@ test.skip('makeActor', async () => { ingress_expiry: new Expiry(300000), }, }), - }, - ]); - - expect(calls[2][0]).toBe('http://localhost/api/v1/read'); - expect(calls[2][1]).toEqual({ - method: 'POST', - headers: { - 'Content-Type': 'application/cbor', - }, - body: cbor.encode({ - content: { - request_type: 'request_status', - request_id: expectedCallRequestId, - ingress_expiry: new Expiry(300000), - }, - }), - }); + }); - expect(calls[3][0]).toBe('http://localhost/api/v1/read'); - expect(calls[3][1]).toEqual({ - method: 'POST', - headers: { - 'Content-Type': 'application/cbor', - }, - body: cbor.encode({ - content: { - request_type: 'request_status', - request_id: expectedCallRequestId, - ingress_expiry: new Expiry(300000), + expect(calls[4][0]).toBe('http://localhost/api/v1/read'); + expect(calls[4][1]).toEqual({ + method: 'POST', + headers: { + 'Content-Type': 'application/cbor', }, - }), + body: cbor.encode({ + content: { + request_type: 'request_status', + request_id: expectedCallRequestId, + ingress_expiry: new Expiry(300000), + }, + }), + }); }); - - expect(calls[4][0]).toBe('http://localhost/api/v1/read'); - expect(calls[4][1]).toEqual({ - method: 'POST', - headers: { - 'Content-Type': 'application/cbor', - }, - body: cbor.encode({ - content: { - request_type: 'request_status', - request_id: expectedCallRequestId, - ingress_expiry: new Expiry(300000), - }, - }), + it('should allow its agent to be invalidated', async () => { + const mockFetch = jest.fn(); + const actorInterface = () => { + return IDL.Service({ + greet: IDL.Func([IDL.Text], [IDL.Text]), + }); + }; + const httpAgent = new HttpAgent({ fetch: mockFetch, host: 'http://localhost' }); + const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c'); + const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent }); + + Actor.agentOf(actor).invalidateIdentity(); + + try { + await actor.greet('test'); + } catch (error) { + expect(error.message).toBe( + "This identity has expired due this application's security policy. Please refresh your authentication.", + ); + } }); }); diff --git a/packages/agent/src/agent/api.ts b/packages/agent/src/agent/api.ts index 6f79fc9b7..4f8d2a6b3 100644 --- a/packages/agent/src/agent/api.ts +++ b/packages/agent/src/agent/api.ts @@ -1,6 +1,7 @@ import { Principal } from '@dfinity/principal'; import { RequestId } from '../request_id'; import { JsonObject } from '@dfinity/candid'; +import { Identity } from '..'; /** * Codes used by the replica for rejecting a message. @@ -158,4 +159,20 @@ export interface Agent { * function by default. */ fetchRootKey(): Promise; + /** + * If an application needs to invalidate an identity under certain conditions, an `Agent` may expose an `invalidateIdentity` method. + * Invoking this method will set the inner identity used by the `Agent` to `null`. + * + * A use case for this would be - after a certain period of inactivity, a secure application chooses to invalidate the identity of any `HttpAgent` instances. An invalid identity can be replaced by `Agent.replaceIdentity` + */ + invalidateIdentity?(): void; + /** + * If an application needs to replace an identity under certain conditions, an `Agent` may expose a `replaceIdentity` method. + * Invoking this method will set the inner identity used by the `Agent` to a newly provided identity. + * + * A use case for this would be - after authenticating using `@dfinity/auth-client`, you can replace the `AnonymousIdentity` of your `Actor` with a `DelegationIdentity`. + * + * ```Actor.agentOf(defaultActor).replaceIdentity(await authClient.getIdentity());``` + */ + replaceIdentity?(identity: Identity): void; }