From ca2c5b4d50c94447be3759d4a875df9fd27556cd Mon Sep 17 00:00:00 2001 From: Jason Hartman Date: Fri, 22 Aug 2025 11:41:39 -0700 Subject: [PATCH 1/6] test(client): support read-only clients Configure presence multi-client testing to have a single writer and all others read only. Add test infrastructure support to have uniquely reader clients in a session. Token provider need only specify write scope during attach. --- .../src/insecureTokenProvider.ts | 58 +++++++++---------- .../src/test/AzureTokenFactory.ts | 3 +- .../src/test/multiprocess/childClient.ts | 16 +++-- .../src/test/multiprocess/messageTypes.ts | 3 + .../test/multiprocess/orchestratorUtils.ts | 16 ++++- 5 files changed, 57 insertions(+), 39 deletions(-) diff --git a/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts b/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts index 91b458a5b9a6..2332d9a7c6e9 100644 --- a/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts +++ b/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts @@ -38,43 +38,39 @@ export class InsecureTokenProvider implements ITokenProvider { * @defaultValue [ ScopeType.DocRead, ScopeType.DocWrite, ScopeType.SummaryWrite ] */ private readonly scopes?: ScopeType[], + + /** + * Optional. Override of attach container scopes. If a param is not provided, + * InsecureTokenProvider will use the value of {@link scopes}. + * + * @remarks Common use of this parameter is to allow write for container + * attach and just read for all other access. Effectively can create a + * create and then read-only client. + * + * @param attachContainerScopes - See {@link @fluidframework/protocol-definitions#ITokenClaims.scopes} + * + * @defaultValue {@link scopes} + */ + private readonly attachContainerScopes?: ScopeType[], ) {} - /** - * {@inheritDoc @fluidframework/routerlicious-driver#ITokenProvider.fetchOrdererToken} - */ - public async fetchOrdererToken( + private readonly fetchToken = async ( tenantId: string, documentId?: string, - ): Promise { + ): Promise => { + const generalScopes = this.scopes ?? [ + ScopeType.DocRead, + ScopeType.DocWrite, + ScopeType.SummaryWrite, + ]; + const scopes = (documentId ? undefined : this.attachContainerScopes) ?? generalScopes; return { fromCache: true, - jwt: generateToken( - tenantId, - this.tenantKey, - this.scopes ?? [ScopeType.DocRead, ScopeType.DocWrite, ScopeType.SummaryWrite], - documentId, - this.user, - ), + jwt: generateToken(tenantId, this.tenantKey, scopes, documentId, this.user), }; - } + }; - /** - * {@inheritDoc @fluidframework/routerlicious-driver#ITokenProvider.fetchStorageToken} - */ - public async fetchStorageToken( - tenantId: string, - documentId: string, - ): Promise { - return { - fromCache: true, - jwt: generateToken( - tenantId, - this.tenantKey, - this.scopes ?? [ScopeType.DocRead, ScopeType.DocWrite, ScopeType.SummaryWrite], - documentId, - this.user, - ), - }; - } + public readonly fetchOrdererToken = this.fetchToken; + + public readonly fetchStorageToken = this.fetchToken; } diff --git a/packages/service-clients/end-to-end-tests/azure-client/src/test/AzureTokenFactory.ts b/packages/service-clients/end-to-end-tests/azure-client/src/test/AzureTokenFactory.ts index e247b73db4db..c6d8c0a5d19f 100644 --- a/packages/service-clients/end-to-end-tests/azure-client/src/test/AzureTokenFactory.ts +++ b/packages/service-clients/end-to-end-tests/azure-client/src/test/AzureTokenFactory.ts @@ -11,6 +11,7 @@ export function createAzureTokenProvider( id: string, name: string, scopes?: ScopeType[], + attachScopes?: ScopeType[], ): ITokenProvider { const key = process.env.azure__fluid__relay__service__key as string; if (key) { @@ -18,7 +19,7 @@ export function createAzureTokenProvider( id, name, }; - return new InsecureTokenProvider(key, userConfig, scopes); + return new InsecureTokenProvider(key, userConfig, scopes, attachScopes); } else { throw new Error("Cannot create token provider."); } diff --git a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/childClient.ts b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/childClient.ts index 567b629e41a6..45acccc1fcfe 100644 --- a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/childClient.ts +++ b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/childClient.ts @@ -13,6 +13,7 @@ import { } from "@fluidframework/azure-client"; import { AttachState } from "@fluidframework/container-definitions"; import { ConnectionState } from "@fluidframework/container-loader"; +import type { ScopeType } from "@fluidframework/driver-definitions/legacy"; import type { ContainerSchema, IFluidContainer } from "@fluidframework/fluid-static"; import { getPresence, @@ -26,10 +27,8 @@ import { import { InsecureTokenProvider } from "@fluidframework/test-runtime-utils/internal"; import { timeoutPromise } from "@fluidframework/test-utils/internal"; -import type { ScopeType } from "../AzureClientFactory.js"; import { createAzureTokenProvider } from "../AzureTokenFactory.js"; import { TestDataObject } from "../TestDataObject.js"; -import type { configProvider } from "../utils.js"; import type { MessageFromChild, MessageToChild, UserIdAndName } from "./messageTypes.js"; @@ -54,8 +53,8 @@ if (useAzure && endPoint === undefined) { const getOrCreatePresenceContainer = async ( id: string | undefined, user: UserIdAndName, - config?: ReturnType, scopes?: ScopeType[], + createScopes?: ScopeType[], ): Promise<{ container: IFluidContainer; presence: Presence; @@ -68,12 +67,17 @@ const getOrCreatePresenceContainer = async ( const connectionProps: AzureRemoteConnectionConfig | AzureLocalConnectionConfig = useAzure ? { tenantId, - tokenProvider: createAzureTokenProvider(user.id ?? "foo", user.name ?? "bar", scopes), + tokenProvider: createAzureTokenProvider( + user.id ?? "foo", + user.name ?? "bar", + scopes, + createScopes, + ), endpoint: endPoint, type: "remote", } : { - tokenProvider: new InsecureTokenProvider("fooBar", user, scopes), + tokenProvider: new InsecureTokenProvider("fooBar", user, scopes, createScopes), endpoint: "http://localhost:7071", type: "local", }; @@ -319,6 +323,8 @@ class MessageHandler { const { container, presence, containerId } = await getOrCreatePresenceContainer( msg.containerId, msg.user, + msg.scopes, + msg.createScopes, ); this.container = container; this.presence = presence; diff --git a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/messageTypes.ts b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/messageTypes.ts index e6b6833555bd..d0eaaa9e6426 100644 --- a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/messageTypes.ts +++ b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/messageTypes.ts @@ -5,6 +5,7 @@ // eslint-disable-next-line import/no-internal-modules import type { JsonSerializable } from "@fluidframework/core-interfaces/internal"; +import type { ScopeType } from "@fluidframework/driver-definitions/legacy"; import type { AttendeeId } from "@fluidframework/presence/beta"; export interface UserIdAndName { @@ -40,6 +41,8 @@ interface PingCommand { export interface ConnectCommand { command: "connect"; user: UserIdAndName; + scopes: ScopeType[]; + createScopes?: ScopeType[]; /** * The ID of the Fluid container to connect to. * If not provided, a new Fluid container will be created. diff --git a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts index 8479dda2f658..98ed49bf298c 100644 --- a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts +++ b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts @@ -5,6 +5,7 @@ import { fork, type ChildProcess } from "node:child_process"; +import { ScopeType } from "@fluidframework/driver-definitions/legacy"; import type { AttendeeId } from "@fluidframework/presence/beta"; import { timeoutAwait, timeoutPromise } from "@fluidframework/test-utils/internal"; @@ -80,13 +81,18 @@ export async function forkChildProcesses( * * @param id - Suffix used to construct stable test user identity. */ -export function composeConnectMessage(id: string | number): ConnectCommand { +function composeConnectMessage( + id: string | number, + scopes: ScopeType[] = [ScopeType.DocRead], +): ConnectCommand { return { command: "connect", user: { id: `test-user-id-${id}`, name: `test-user-name-${id}`, }, + scopes, + createScopes: [ScopeType.DocWrite], }; } @@ -122,7 +128,13 @@ export async function connectChildProcesses( } }); }); - firstChild.send(composeConnectMessage(0)); + { + // Note that DocWrite is used to have this attendee be the "leader". + // DocRead would also be valid as DocWrite is specified for attach when there + // is no document id (container id). + const connectContainerCreator = composeConnectMessage(0, [ScopeType.DocWrite]); + firstChild.send(connectContainerCreator); + } const { containerCreatorAttendeeId, containerId } = await timeoutAwait( containerReadyPromise, { From 3797dd87229f7afe9926b51afd9ce2732ce90bae Mon Sep 17 00:00:00 2001 From: Jason Hartman Date: Sun, 31 Aug 2025 11:58:27 -0700 Subject: [PATCH 2/6] test(client): fix tsdoc spec --- .../runtime/test-runtime-utils/src/insecureTokenProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts b/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts index 2332d9a7c6e9..131bdcfc213c 100644 --- a/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts +++ b/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts @@ -41,7 +41,7 @@ export class InsecureTokenProvider implements ITokenProvider { /** * Optional. Override of attach container scopes. If a param is not provided, - * InsecureTokenProvider will use the value of {@link scopes}. + * InsecureTokenProvider will use the value of {@link InsecureTokenProvider.scopes}. * * @remarks Common use of this parameter is to allow write for container * attach and just read for all other access. Effectively can create a @@ -49,7 +49,7 @@ export class InsecureTokenProvider implements ITokenProvider { * * @param attachContainerScopes - See {@link @fluidframework/protocol-definitions#ITokenClaims.scopes} * - * @defaultValue {@link scopes} + * @defaultValue {@link InsecureTokenProvider.scopes} */ private readonly attachContainerScopes?: ScopeType[], ) {} From 8d9eb5844087174a9633d1f84210eac213c52d36 Mon Sep 17 00:00:00 2001 From: Jason Hartman Date: Sun, 31 Aug 2025 12:23:45 -0700 Subject: [PATCH 3/6] test(client): use private method as Copilot suggests for efficiency --- .../test-runtime-utils/src/insecureTokenProvider.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts b/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts index 131bdcfc213c..554b3ec3992b 100644 --- a/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts +++ b/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts @@ -15,6 +15,7 @@ import { IInsecureUser } from "./insecureUsers.js"; * * As the name implies, this is not secure and should not be used in production. * It simply makes examples where authentication is not relevant easier to bootstrap. + * @sealed * @internal */ export class InsecureTokenProvider implements ITokenProvider { @@ -54,10 +55,7 @@ export class InsecureTokenProvider implements ITokenProvider { private readonly attachContainerScopes?: ScopeType[], ) {} - private readonly fetchToken = async ( - tenantId: string, - documentId?: string, - ): Promise => { + private async fetchToken(tenantId: string, documentId?: string): Promise { const generalScopes = this.scopes ?? [ ScopeType.DocRead, ScopeType.DocWrite, @@ -68,9 +66,9 @@ export class InsecureTokenProvider implements ITokenProvider { fromCache: true, jwt: generateToken(tenantId, this.tenantKey, scopes, documentId, this.user), }; - }; + } - public readonly fetchOrdererToken = this.fetchToken; + public readonly fetchOrdererToken = this.fetchToken.bind(this); - public readonly fetchStorageToken = this.fetchToken; + public readonly fetchStorageToken = this.fetchToken.bind(this); } From 80aa92fef3d4250971fbdd521125e15c16f95397 Mon Sep 17 00:00:00 2001 From: Jason Hartman Date: Sun, 31 Aug 2025 17:45:35 -0700 Subject: [PATCH 4/6] test(client): fix do not enable read attendees (ahead of protocol update) --- .../azure-client/src/test/multiprocess/orchestratorUtils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts index 98ed49bf298c..40af33ba7efd 100644 --- a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts +++ b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts @@ -149,7 +149,10 @@ export async function connectChildProcesses( attendeeIdPromises.push(Promise.resolve(containerCreatorAttendeeId)); continue; } - const message = composeConnectMessage(index); + // TODO: AB#45620: "Presence: perf: update Join pattern for scale" can handle + // larger counts of read-only attendees. Without protocol changes tests with + // 20+ attendees exceed current limits. + const message = composeConnectMessage(index, [ScopeType.DocWrite]); message.containerId = containerId; attendeeIdPromises.push( new Promise((resolve, reject) => { From c947af3ce5fb713f53febdec8034c27f483b166f Mon Sep 17 00:00:00 2001 From: Jason Hartman Date: Mon, 1 Sep 2025 01:52:26 -0700 Subject: [PATCH 5/6] test(client): re-enable read attendees with skipped failing test --- .../test/multiprocess/orchestratorUtils.ts | 32 ++-- .../test/multiprocess/presenceTest.spec.ts | 143 ++++++++++-------- 2 files changed, 101 insertions(+), 74 deletions(-) diff --git a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts index 40af33ba7efd..486325a3fdc2 100644 --- a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts +++ b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/orchestratorUtils.ts @@ -104,7 +104,7 @@ function composeConnectMessage( */ export async function connectChildProcesses( childProcesses: ChildProcess[], - readyTimeoutMs: number, + { writeClients, readyTimeoutMs }: { writeClients: number; readyTimeoutMs: number }, ): Promise<{ containerCreatorAttendeeId: AttendeeId; attendeeIdPromises: Promise[]; @@ -132,7 +132,9 @@ export async function connectChildProcesses( // Note that DocWrite is used to have this attendee be the "leader". // DocRead would also be valid as DocWrite is specified for attach when there // is no document id (container id). - const connectContainerCreator = composeConnectMessage(0, [ScopeType.DocWrite]); + const connectContainerCreator = composeConnectMessage(0, [ + writeClients > 0 ? ScopeType.DocWrite : ScopeType.DocRead, + ]); firstChild.send(connectContainerCreator); } const { containerCreatorAttendeeId, containerId } = await timeoutAwait( @@ -149,10 +151,9 @@ export async function connectChildProcesses( attendeeIdPromises.push(Promise.resolve(containerCreatorAttendeeId)); continue; } - // TODO: AB#45620: "Presence: perf: update Join pattern for scale" can handle - // larger counts of read-only attendees. Without protocol changes tests with - // 20+ attendees exceed current limits. - const message = composeConnectMessage(index, [ScopeType.DocWrite]); + const message = composeConnectMessage(index, [ + index < writeClients ? ScopeType.DocWrite : ScopeType.DocRead, + ]); message.containerId = containerId; attendeeIdPromises.push( new Promise((resolve, reject) => { @@ -175,9 +176,17 @@ export async function connectChildProcesses( */ export async function connectAndWaitForAttendees( children: ChildProcess[], - attendeeCountRequired: number, - childConnectTimeoutMs: number, - attendeesJoinedTimeoutMs: number, + { + writeClients, + attendeeCountRequired, + childConnectTimeoutMs, + attendeesJoinedTimeoutMs, + }: { + writeClients: number; + attendeeCountRequired: number; + childConnectTimeoutMs: number; + attendeesJoinedTimeoutMs: number; + }, earlyExitPromise: Promise, ): Promise<{ containerCreatorAttendeeId: AttendeeId }> { const attendeeConnectedPromise = new Promise((resolve) => { @@ -191,7 +200,10 @@ export async function connectAndWaitForAttendees( } }); }); - const connectResult = await connectChildProcesses(children, childConnectTimeoutMs); + const connectResult = await connectChildProcesses(children, { + writeClients, + readyTimeoutMs: childConnectTimeoutMs, + }); Promise.all(connectResult.attendeeIdPromises).catch((error) => { console.error("Error connecting children:", error); }); diff --git a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/presenceTest.spec.ts b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/presenceTest.spec.ts index 36d1a8e8c4c3..ca52a035458b 100644 --- a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/presenceTest.spec.ts +++ b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/presenceTest.spec.ts @@ -75,7 +75,7 @@ describe(`Presence with AzureClient`, () => { /** * Timeout for presence attendees to connect {@link AttendeeConnectedEvent} */ - const attendeeJoinedTimeoutMs = 2000; + const attendeesJoinedTimeoutMs = 2000; /** * Timeout for workspace registration {@link WorkspaceRegisteredEvent} */ @@ -89,68 +89,83 @@ describe(`Presence with AzureClient`, () => { */ const getStateTimeoutMs = 5000; - it(`announces 'attendeeConnected' when remote client joins session [${numClients} clients]`, async () => { - // Setup - const { children, childErrorPromise } = await forkChildProcesses( - numClients, - afterCleanUp, - ); - - // Further Setup with Act and Verify - await connectAndWaitForAttendees( - children, - numClients - 1, - childConnectTimeoutMs, - attendeeJoinedTimeoutMs, - childErrorPromise, - ); - }); + for (const writeClients of [numClients, 1]) { + it(`announces 'attendeeConnected' when remote client joins session [${numClients} clients, ${writeClients} writers]`, async () => { + // Setup + const { children, childErrorPromise } = await forkChildProcesses( + numClients, + afterCleanUp, + ); - it(`announces 'attendeeDisconnected' when remote client disconnects [${numClients} clients]`, async () => { - // Setup - const { children, childErrorPromise } = await forkChildProcesses( - numClients, - afterCleanUp, - ); - - const connectResult = await connectAndWaitForAttendees( - children, - numClients - 1, - childConnectTimeoutMs, - attendeeJoinedTimeoutMs, - childErrorPromise, - ); - - const childDisconnectTimeoutMs = 10_000; - - const waitForDisconnected = children.map(async (child, index) => - index === 0 - ? Promise.resolve() - : timeoutPromise( - (resolve) => { - child.on("message", (msg: MessageFromChild) => { - if ( - msg.event === "attendeeDisconnected" && - msg.attendeeId === connectResult.containerCreatorAttendeeId - ) { - console.log(`Child[${index}] saw creator disconnect`); - resolve(); - } - }); - }, - { - durationMs: childDisconnectTimeoutMs, - errorMsg: `Attendee[${index}] Disconnected Timeout`, - }, - ), - ); - - // Act - disconnect first child process - children[0].send({ command: "disconnectSelf" }); - - // Verify - wait for all 'attendeeDisconnected' events - await Promise.race([Promise.all(waitForDisconnected), childErrorPromise]); - }); + // Further Setup with Act and Verify + await connectAndWaitForAttendees( + children, + { + writeClients, + attendeeCountRequired: numClients - 1, + childConnectTimeoutMs, + attendeesJoinedTimeoutMs, + }, + childErrorPromise, + ); + }); + + it(`announces 'attendeeDisconnected' when remote client disconnects [${numClients} clients, ${writeClients} writers]`, async function () { + // TODO: AB#45620: "Presence: perf: update Join pattern for scale" can handle + // larger counts of read-only attendees. Without protocol changes tests with + // 20+ attendees exceed current limits. + if (numClients >= 20 && writeClients === 1) { + this.skip(); + } + + // Setup + const { children, childErrorPromise } = await forkChildProcesses( + numClients, + afterCleanUp, + ); + + const connectResult = await connectAndWaitForAttendees( + children, + { + writeClients, + attendeeCountRequired: numClients - 1, + childConnectTimeoutMs, + attendeesJoinedTimeoutMs, + }, + childErrorPromise, + ); + + const childDisconnectTimeoutMs = 10_000; + + const waitForDisconnected = children.map(async (child, index) => + index === 0 + ? Promise.resolve() + : timeoutPromise( + (resolve) => { + child.on("message", (msg: MessageFromChild) => { + if ( + msg.event === "attendeeDisconnected" && + msg.attendeeId === connectResult.containerCreatorAttendeeId + ) { + console.log(`Child[${index}] saw creator disconnect`); + resolve(); + } + }); + }, + { + durationMs: childDisconnectTimeoutMs, + errorMsg: `Attendee[${index}] Disconnected Timeout`, + }, + ), + ); + + // Act - disconnect first child process + children[0].send({ command: "disconnectSelf" }); + + // Verify - wait for all 'attendeeDisconnected' events + await Promise.race([Promise.all(waitForDisconnected), childErrorPromise]); + }); + } // This test suite focuses on the synchronization of Latest state between clients. // NOTE: For testing purposes child clients will expect a Latest value of type string. @@ -167,7 +182,7 @@ describe(`Presence with AzureClient`, () => { ({ children, childErrorPromise } = await forkChildProcesses(numClients, afterCleanUp)); ({ containerCreatorAttendeeId, attendeeIdPromises } = await connectChildProcesses( children, - childConnectTimeoutMs, + { writeClients: numClients, readyTimeoutMs: childConnectTimeoutMs }, )); await Promise.all(attendeeIdPromises); remoteClients = children.filter((_, index) => index !== 0); @@ -243,7 +258,7 @@ describe(`Presence with AzureClient`, () => { ({ children, childErrorPromise } = await forkChildProcesses(numClients, afterCleanUp)); ({ containerCreatorAttendeeId, attendeeIdPromises } = await connectChildProcesses( children, - childConnectTimeoutMs, + { writeClients: numClients, readyTimeoutMs: childConnectTimeoutMs }, )); await Promise.all(attendeeIdPromises); remoteClients = children.filter((_, index) => index !== 0); From c29440b3d98b6feb949a8dfaa0247ec6881bcc6b Mon Sep 17 00:00:00 2001 From: Jason Hartman Date: Tue, 2 Sep 2025 09:39:02 -0700 Subject: [PATCH 6/6] docs(client-test): typo correction --- .../runtime/test-runtime-utils/src/insecureTokenProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts b/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts index 554b3ec3992b..995d49ed423e 100644 --- a/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts +++ b/packages/runtime/test-runtime-utils/src/insecureTokenProvider.ts @@ -46,7 +46,7 @@ export class InsecureTokenProvider implements ITokenProvider { * * @remarks Common use of this parameter is to allow write for container * attach and just read for all other access. Effectively can create a - * create and then read-only client. + * container and then read-only client. * * @param attachContainerScopes - See {@link @fluidframework/protocol-definitions#ITokenClaims.scopes} *