From 820b9d81b4f85fa1e827b68b60f9c796011df361 Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Mon, 10 Aug 2026 08:42:21 +0200 Subject: [PATCH 1/3] feat(azure-identity,msal-node): add MockAuthProvider test double for auth module Add a configurable IAuthProvider test double, exported from a new /mock subpath in each package (@equinor/fusion-framework-module-azure-identity/mock, @equinor/fusion-framework-module-msal-node/mock). Unlike token_only mode's static-token providers (AuthProviderTokenOnly / AuthTokenProvider), MockAuthProvider actually implements login/logout, moving between signed-in and signed-out state, and exposes setAccount, setAccessToken and setExpiresOn so a test can control the returned token and simulate an expired token to exercise refresh logic. No real network calls are made. Implemented as two mirrored (twin) implementations rather than a shared package, since azure-identity's and msal-node's IAuthProvider interfaces differ in login's return type (AuthRecord vs. AuthenticationResult) and neither package depends on the other. Registers via enableAuthMock(configurator, configure?), a full 'auth' module replacement mirroring @equinor/fusion-framework-module-msal/mock's enableMsalMock, so no special-cased wiring is needed in either module. Closes equinor/fusion-core-tasks#1665. --- ...odule-azure-identity_mock-auth-provider.md | 25 ++ .../module-msal-node_mock-auth-provider.md | 25 ++ packages/modules/azure-identity/README.md | 38 ++++ packages/modules/azure-identity/package.json | 18 +- .../__tests__/mock/MockAuthProvider.test.ts | 124 ++++++++++ .../src/mock/MockAuthProvider.ts | 192 ++++++++++++++++ .../modules/azure-identity/src/mock/index.ts | 32 +++ .../modules/azure-identity/src/mock/module.ts | 67 ++++++ packages/modules/azure-identity/tsconfig.json | 2 +- .../modules/azure-identity/vitest.config.ts | 11 + packages/modules/msal-node/README.md | 39 ++++ packages/modules/msal-node/package.json | 21 +- .../__tests__/mock/MockAuthProvider.test.ts | 125 ++++++++++ .../msal-node/src/mock/MockAuthProvider.ts | 214 ++++++++++++++++++ packages/modules/msal-node/src/mock/index.ts | 32 +++ packages/modules/msal-node/src/mock/module.ts | 67 ++++++ packages/modules/msal-node/tsconfig.json | 2 +- packages/modules/msal-node/vitest.config.ts | 11 + pnpm-lock.yaml | 135 ++++++++--- 19 files changed, 1143 insertions(+), 37 deletions(-) create mode 100644 .changeset/module-azure-identity_mock-auth-provider.md create mode 100644 .changeset/module-msal-node_mock-auth-provider.md create mode 100644 packages/modules/azure-identity/src/__tests__/mock/MockAuthProvider.test.ts create mode 100644 packages/modules/azure-identity/src/mock/MockAuthProvider.ts create mode 100644 packages/modules/azure-identity/src/mock/index.ts create mode 100644 packages/modules/azure-identity/src/mock/module.ts create mode 100644 packages/modules/azure-identity/vitest.config.ts create mode 100644 packages/modules/msal-node/src/__tests__/mock/MockAuthProvider.test.ts create mode 100644 packages/modules/msal-node/src/mock/MockAuthProvider.ts create mode 100644 packages/modules/msal-node/src/mock/index.ts create mode 100644 packages/modules/msal-node/src/mock/module.ts create mode 100644 packages/modules/msal-node/vitest.config.ts diff --git a/.changeset/module-azure-identity_mock-auth-provider.md b/.changeset/module-azure-identity_mock-auth-provider.md new file mode 100644 index 0000000000..1432464196 --- /dev/null +++ b/.changeset/module-azure-identity_mock-auth-provider.md @@ -0,0 +1,25 @@ +--- +"@equinor/fusion-framework-module-azure-identity": minor +--- + +Add `MockAuthProvider`, a configurable `IAuthProvider` test double, exported from a new `/mock` subpath (`@equinor/fusion-framework-module-azure-identity/mock`). + +Unlike `token_only` mode's `AuthProviderTokenOnly` — a single fixed token where `login`/`logout` always throw — `MockAuthProvider` actually implements `login`/`logout`: a test can drive the provider from signed-out to signed-in (and back), and control the returned access token and its `expiresOn`, including setting an expiry in the past to exercise a consuming application's own token-refresh logic. No real `@azure/identity` network calls are made. + +```typescript +import { enableAuthMock } from '@equinor/fusion-framework-module-azure-identity/mock'; + +const auth = enableAuthMock(configurator, (auth) => { + auth.setAccount({ username: 'ada@equinor.com', signedOut: true }); +}); + +await auth.login({ request: { scopes: ['User.Read'] } }); +const token = await auth.acquireAccessToken({ request: { scopes: ['User.Read'] } }); + +// simulate an expired token +auth.setExpiresOn(new Date(Date.now() - 1000)); +``` + +`MockAuthProvider` registers as the `'auth'` module's provider exactly like any real implementation — no special-cased wiring in the module itself. This does not change or replace `token_only` mode, which remains the right choice for CI/CD scenarios needing a static token. + +Related: equinor/fusion-core-tasks#1665. diff --git a/.changeset/module-msal-node_mock-auth-provider.md b/.changeset/module-msal-node_mock-auth-provider.md new file mode 100644 index 0000000000..718d766f79 --- /dev/null +++ b/.changeset/module-msal-node_mock-auth-provider.md @@ -0,0 +1,25 @@ +--- +"@equinor/fusion-framework-module-msal-node": minor +--- + +Add `MockAuthProvider`, a configurable `IAuthProvider` test double, exported from a new `/mock` subpath (`@equinor/fusion-framework-module-msal-node/mock`). + +Unlike `token_only` mode's `AuthTokenProvider` — a single fixed token where `login`/`logout` always throw — `MockAuthProvider` actually implements `login`/`logout`: a test can drive the provider from signed-out to signed-in (and back), and control the returned `AuthenticationResult`'s access token and `expiresOn`, including setting an expiry in the past to exercise a consuming application's own token-refresh logic. No real `@azure/msal-node` network calls are made, and no browser or local callback server is opened. + +```typescript +import { enableAuthMock } from '@equinor/fusion-framework-module-msal-node/mock'; + +const auth = enableAuthMock(configurator, (auth) => { + auth.setAccount({ username: 'ada@equinor.com', signedOut: true }); +}); + +await auth.login({ request: { scopes: ['User.Read'] } }); +const token = await auth.acquireAccessToken({ request: { scopes: ['User.Read'] } }); + +// simulate an expired token +auth.setExpiresOn(new Date(Date.now() - 1000)); +``` + +`MockAuthProvider` registers as the `'auth'` module's provider exactly like any real implementation — no special-cased wiring in the module itself. This does not change or replace `token_only` mode, which remains the right choice for CI/CD scenarios needing a static token. + +Related: equinor/fusion-core-tasks#1665. diff --git a/packages/modules/azure-identity/README.md b/packages/modules/azure-identity/README.md index de3dd2ffc7..aea2b84f4d 100644 --- a/packages/modules/azure-identity/README.md +++ b/packages/modules/azure-identity/README.md @@ -113,6 +113,35 @@ await framework.auth.logout(); Returns a pre-obtained token string. `login()` and `logout()` throw. +## Testing + +`token_only` mode already avoids real Azure AD traffic, but it is rigid: a single +fixed token, no expiry simulation, and `login`/`logout` always throw. For tests +that need to exercise sign-in, sign-out, or token-refresh/expiry logic, use +`MockAuthProvider` from the `/mock` subpath instead: + +```typescript +import { enableAuthMock } from '@equinor/fusion-framework-module-azure-identity/mock'; + +// default identity, already signed in +const auth = enableAuthMock(configurator); + +// or seed the identity/state up front +enableAuthMock(configurator, (auth) => { + auth.setAccount({ username: 'ada@equinor.com', signedOut: true }); +}); + +// drive login/logout and inspect the resulting tokens directly +await auth.login({ request: { scopes: ['User.Read'] } }); +const token = await auth.acquireAccessToken({ request: { scopes: ['User.Read'] } }); + +// simulate an expired token, to exercise a consuming application's refresh path +auth.setExpiresOn(new Date(Date.now() - 1000)); +``` + +`MockAuthProvider` registers exactly like any other `IAuthProvider` implementation +— no special-cased wiring — and makes no real `@azure/identity` network calls. + ## Token cache persistence The module registers `cachePersistencePlugin` from `@azure/identity-cache-persistence` at load time, enabling encrypted OS-level token caching: @@ -167,3 +196,12 @@ enableAzureIdentityAuth(configurator, (builder) => { | `IAuthProvider` | Auth provider interface | | `AzureIdentityModule` | Module type for generic parameters | | `azureIdentityModule` | Raw module definition | + +### `/mock` subpath + +| Export | Description | +|---|---| +| `MockAuthProvider` | Configurable `IAuthProvider` test double — `login`/`logout` work, token and expiry are settable | +| `MockAuthProviderOptions` | Constructor options for `MockAuthProvider` | +| `enableAuthMock` | Registers a `MockAuthProvider` as the `'auth'` module and returns it | +| `createAuthMockModule` | Builds the mock module descriptor for a given `MockAuthProvider` | diff --git a/packages/modules/azure-identity/package.json b/packages/modules/azure-identity/package.json index 9852a08015..901e7551ed 100644 --- a/packages/modules/azure-identity/package.json +++ b/packages/modules/azure-identity/package.json @@ -9,10 +9,25 @@ ".": { "import": "./dist/esm/index.js", "types": "./dist/types/index.d.ts" + }, + "./mock": { + "import": "./dist/esm/mock/index.js", + "types": "./dist/types/mock/index.d.ts" + } + }, + "typesVersions": { + "*": { + ".": [ + "dist/types/index.d.ts" + ], + "mock": [ + "dist/types/mock/index.d.ts" + ] } }, "scripts": { "build": "tsc -b", + "test": "vitest run", "prepack": "pnpm build" }, "keywords": [ @@ -43,6 +58,7 @@ "@azure/msal-node-extensions": "^5.1.4" }, "devDependencies": { - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vitest": "^4.1.0" } } diff --git a/packages/modules/azure-identity/src/__tests__/mock/MockAuthProvider.test.ts b/packages/modules/azure-identity/src/__tests__/mock/MockAuthProvider.test.ts new file mode 100644 index 0000000000..6acff00dff --- /dev/null +++ b/packages/modules/azure-identity/src/__tests__/mock/MockAuthProvider.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { ModulesConfigurator } from '@equinor/fusion-framework-module'; + +import { module as realModule } from '../../module'; +import type { IAuthProvider } from '../../AuthProvider.interface'; +import { createAuthMockModule, enableAuthMock, MockAuthProvider } from '../../mock'; + +describe('MockAuthProvider', () => { + it('is signed in with a default identity out of the box', async () => { + const auth = new MockAuthProvider(); + + const token = await auth.acquireToken({ request: { scopes: ['User.Read'] } }); + + expect(token?.accessToken).toBeDefined(); + expect(token?.expiresOn).toBeNull(); + }); + + it('throws from acquireToken/acquireAccessToken when constructed signedOut', async () => { + const auth = new MockAuthProvider({ signedOut: true }); + + await expect(auth.acquireToken({ request: { scopes: [] } })).rejects.toThrow(/not signed in/); + await expect(auth.acquireAccessToken({ request: { scopes: [] } })).rejects.toThrow( + /not signed in/, + ); + }); + + it('signs in on login, resolving the configured identity', async () => { + const auth = new MockAuthProvider({ + signedOut: true, + account: { username: 'ada@equinor.com' }, + }); + + const record = await auth.login({ request: { scopes: ['User.Read'] } }); + + expect(record.username).toBe('ada@equinor.com'); + await expect( + auth.acquireAccessToken({ request: { scopes: ['User.Read'] } }), + ).resolves.toBeDefined(); + }); + + it('signs out on logout, without discarding the identity for a later login', async () => { + const auth = new MockAuthProvider(); + + await auth.logout(); + await expect(auth.acquireToken({ request: { scopes: [] } })).rejects.toThrow(/not signed in/); + + const record = await auth.login({ request: { scopes: [] } }); + expect(record.username).toBe('test.user@equinor.com'); + }); + + it('setAccount merges identity fields and can flip the signed-in state', async () => { + const auth = new MockAuthProvider(); + + auth.setAccount({ username: 'ada@equinor.com', signedOut: true }); + + await expect(auth.acquireToken({ request: { scopes: [] } })).rejects.toThrow(/not signed in/); + + const record = await auth.login({ request: { scopes: [] } }); + expect(record.username).toBe('ada@equinor.com'); + }); + + it('setAccessToken replaces the token returned by acquireToken/acquireAccessToken', async () => { + const auth = new MockAuthProvider(); + + auth.setAccessToken('a-different-token'); + + const token = await auth.acquireToken({ request: { scopes: [] } }); + expect(token?.accessToken).toBe('a-different-token'); + await expect(auth.acquireAccessToken({ request: { scopes: [] } })).resolves.toBe( + 'a-different-token', + ); + }); + + it('setExpiresOn lets a test simulate an already-expired token', async () => { + const auth = new MockAuthProvider(); + const past = new Date(Date.now() - 1000); + + auth.setExpiresOn(past); + + const token = await auth.acquireToken({ request: { scopes: [] } }); + expect(token?.expiresOn).toEqual(past); + expect(token?.expiresOn?.getTime()).toBeLessThan(Date.now()); + }); +}); + +describe('createAuthMockModule', () => { + it('shares the real module name, so it is a drop-in replacement for the "auth" slot', () => { + const mod = createAuthMockModule(); + + expect(mod.name).toBe(realModule.name); + }); + + it('registers the given MockAuthProvider as the module config, unchanged', () => { + const auth = new MockAuthProvider({ account: { username: 'ada@equinor.com' } }); + const mod = createAuthMockModule(auth); + + expect(mod.configure?.()).toBe(auth); + }); +}); + +describe('enableAuthMock', () => { + it('initializes the "auth" module as the MockAuthProvider it registered', async () => { + const configurator = new ModulesConfigurator([]); + const auth = enableAuthMock(configurator); + + const instances = await configurator.initialize(); + + expect((instances as unknown as { auth: IAuthProvider }).auth).toBe(auth); + }); + + it('replaces an auth module that is already registered', async () => { + // A FrameworkConfigurator pre-registers the real auth module, so the mock is + // only useful if registering it afterwards wins + const configurator = new ModulesConfigurator([realModule]); + enableAuthMock(configurator, (auth) => auth.setAccount({ username: 'ada@equinor.com' })); + + const instances = await configurator.initialize(); + const record = await (instances as unknown as { auth: IAuthProvider }).auth.login({ + request: { scopes: [] }, + }); + + expect(record.username).toBe('ada@equinor.com'); + }); +}); diff --git a/packages/modules/azure-identity/src/mock/MockAuthProvider.ts b/packages/modules/azure-identity/src/mock/MockAuthProvider.ts new file mode 100644 index 0000000000..34a096caf4 --- /dev/null +++ b/packages/modules/azure-identity/src/mock/MockAuthProvider.ts @@ -0,0 +1,192 @@ +import type { AuthRecord, IAuthProvider } from '../AuthProvider.interface.js'; + +/** + * The identity {@link MockAuthProvider} signs in by default, and returns to + * when {@link MockAuthProvider.setAccount | setAccount} has not overridden it. + */ +const defaultAccount: AuthRecord = { + username: 'test.user@equinor.com', + tenantId: 'fusion-mock-tenant', + clientId: 'fusion-mock-client', + authority: 'https://login.microsoftonline.com/fusion-mock-tenant', +}; + +/** + * Options for constructing a {@link MockAuthProvider}. + */ +export interface MockAuthProviderOptions { + /** Fields to merge onto the default identity (`test.user@equinor.com`). */ + account?: Partial; + /** + * Start without a signed-in session, while keeping the identity above. + * + * @remarks + * `acquireToken`/`acquireAccessToken` then throw until a test calls + * {@link MockAuthProvider.login | login}, which succeeds as that identity — + * this lets a test drive the sign-in journey rather than assert only on an + * already-signed-in end state. + */ + signedOut?: boolean; + /** Access token returned by {@link MockAuthProvider.acquireToken | acquireToken}. Defaults to a fixed placeholder string. */ + accessToken?: string; + /** Expiry reported by {@link MockAuthProvider.acquireToken | acquireToken}. Defaults to `null` (no known expiry), matching {@link AuthProviderTokenOnly}. */ + expiresOn?: Date | null; +} + +/** + * Configurable {@link IAuthProvider} test double for the `'auth'` module slot. + * + * @remarks + * Unlike `AuthProviderTokenOnly`'s single fixed token, `login`/`logout` actually + * change state here: a test can drive the provider from signed-out to signed-in + * (and back), and control the token and expiry `acquireToken` returns afterwards + * — including an expiry in the past, to exercise a consuming application's own + * refresh logic. + * + * No network calls are made; nothing here talks to Entra ID or `@azure/identity`. + * + * @example Default identity, already signed in + * ```typescript + * import { enableAuthMock } from '@equinor/fusion-framework-module-azure-identity/mock'; + * + * const auth = enableAuthMock(configurator); + * await auth.acquireAccessToken({ request: { scopes: ['User.Read'] } }); // resolves + * ``` + * + * @example Start signed out, then log in + * ```typescript + * const auth = enableAuthMock(configurator, (auth) => auth.setAccount({ signedOut: true })); + * await expect(auth.acquireAccessToken({ request: { scopes } })).rejects.toThrow(); + * + * await auth.login({ request: { scopes } }); + * await auth.acquireAccessToken({ request: { scopes } }); // now resolves + * ``` + * + * @example Simulate an expired token + * ```typescript + * auth.setExpiresOn(new Date(Date.now() - 1000)); + * const { expiresOn } = (await auth.acquireToken({ request: { scopes } }))!; + * expiresOn!.getTime() < Date.now(); // true — a refresh path under test now has something to react to + * ``` + */ +export class MockAuthProvider implements IAuthProvider { + #identity: AuthRecord; + #signedIn: boolean; + #accessToken: string; + #expiresOn: Date | null; + + /** + * @param options - The identity, initial signed-in state, token, and expiry to configure. + */ + constructor(options: MockAuthProviderOptions = {}) { + // `options.account` only overrides the fields a test names, keeping the rest of the default identity + this.#identity = { ...defaultAccount, ...options.account }; + this.#signedIn = !options.signedOut; + this.#accessToken = options.accessToken ?? 'fusion-mock-access-token'; + this.#expiresOn = options.expiresOn ?? null; + } + + /** + * Signs the configured identity in. + * + * Unlike `AuthProviderTokenOnly.login`, this succeeds — moving the provider + * into a signed-in state so subsequent `acquireToken`/`acquireAccessToken` + * calls resolve. + * + * @param _options - Unused; the identity comes from the constructor or {@link setAccount}, not the login request. + * @returns The signed-in {@link AuthRecord}. + */ + async login(_options: { request: { scopes: string[] } }): Promise { + this.#signedIn = true; + return this.#identity; + } + + /** + * Signs out, without discarding the configured identity — a later {@link login} + * signs the same identity back in. + */ + async logout(): Promise { + this.#signedIn = false; + } + + /** + * Returns the configured token and expiry for the signed-in identity. + * + * @param _options - Unused — the same token is returned regardless of scopes. + * @returns The token result. + * @throws When not signed in — call {@link login} first, or construct without `signedOut`. + */ + async acquireToken(_options: { + request: { scopes: string[] }; + }): Promise<{ accessToken: string; expiresOn: Date | null } | null> { + // Mirrors a real provider's failure mode when there is no session to acquire a token for + if (!this.#signedIn) { + throw new Error( + 'MockAuthProvider: not signed in — call login() first, or construct without `signedOut`', + ); + } + return { accessToken: this.#accessToken, expiresOn: this.#expiresOn }; + } + + /** + * Returns the configured access token for the signed-in identity. + * + * @param options - Unused — the same token is returned regardless of scopes. + * @returns The access token string. + * @throws When not signed in — call {@link login} first, or construct without `signedOut`. + */ + async acquireAccessToken(options: { + request: { scopes: string[] }; + interactive?: boolean; + }): Promise { + // acquireToken never resolves `null` in this implementation — it throws instead when not signed in. + const result = await this.acquireToken(options); + // Unreachable today, kept for the `| null` contract acquireToken shares with a real provider + if (!result) { + throw new Error( + 'MockAuthProvider: not signed in — call login() first, or construct without `signedOut`', + ); + } + return result.accessToken; + } + + /** + * Replaces (parts of) the signed-in identity, and/or the signed-in state, + * without going through {@link login}/{@link logout}. + * + * @param account - Fields to merge onto the current identity, plus an optional `signedOut` flag. + * @returns `this`, for chaining. + */ + setAccount(account: Partial & { signedOut?: boolean }): this { + const { signedOut, ...identity } = account; + // Only the fields the caller named are replaced; the rest of the current identity is kept + this.#identity = { ...this.#identity, ...identity }; + // `undefined` means "leave the signed-in state as it is", distinct from explicitly passing `false` + if (signedOut !== undefined) { + this.#signedIn = !signedOut; + } + return this; + } + + /** + * Replaces the access token returned by {@link acquireToken}/{@link acquireAccessToken}. + * + * @param accessToken - The new access token string. + * @returns `this`, for chaining. + */ + setAccessToken(accessToken: string): this { + this.#accessToken = accessToken; + return this; + } + + /** + * Replaces the expiry reported by {@link acquireToken}. + * + * @param expiresOn - The new expiry, or `null` to report no known expiry. + * @returns `this`, for chaining. Pass a date in the past to simulate an expired token. + */ + setExpiresOn(expiresOn: Date | null): this { + this.#expiresOn = expiresOn; + return this; + } +} diff --git a/packages/modules/azure-identity/src/mock/index.ts b/packages/modules/azure-identity/src/mock/index.ts new file mode 100644 index 0000000000..a024c4adb3 --- /dev/null +++ b/packages/modules/azure-identity/src/mock/index.ts @@ -0,0 +1,32 @@ +/** + * Configurable {@link MockAuthProvider | IAuthProvider} test double for + * `@equinor/fusion-framework-module-azure-identity`. + * + * @remarks + * Unlike `token_only` mode's single fixed token, `login`/`logout` actually + * change state, and the returned token and expiry are both configurable — + * so a test can exercise sign-in, sign-out, and token-refresh/expiry paths + * without a real Entra ID tenant. + * + * @example + * ```typescript + * import { enableAuthMock } from '@equinor/fusion-framework-module-azure-identity/mock'; + * + * // default identity, already signed in + * const auth = enableAuthMock(configurator); + * + * // or seed the identity/state up front + * enableAuthMock(configurator, (auth) => { + * auth.setAccount({ username: 'ada@equinor.com', signedOut: true }); + * }); + * ``` + * + * @packageDocumentation + */ +export { MockAuthProvider, type MockAuthProviderOptions } from './MockAuthProvider.js'; +export { + createAuthMockModule, + enableAuthMock, + type AuthMockModule, + type AuthMockConfigFn, +} from './module.js'; diff --git a/packages/modules/azure-identity/src/mock/module.ts b/packages/modules/azure-identity/src/mock/module.ts new file mode 100644 index 0000000000..a7a6504962 --- /dev/null +++ b/packages/modules/azure-identity/src/mock/module.ts @@ -0,0 +1,67 @@ +import type { IModulesConfigurator, Module } from '@equinor/fusion-framework-module'; + +import type { IAuthProvider } from '../AuthProvider.interface.js'; + +import { MockAuthProvider } from './MockAuthProvider.js'; + +/** + * The `'auth'` module backed by a {@link MockAuthProvider} instead of a real credential. + */ +export type AuthMockModule = Module<'auth', IAuthProvider, MockAuthProvider>; + +/** + * Builds the `'auth'` module descriptor for a given {@link MockAuthProvider}. + * + * @remarks + * `configure` returns `auth` directly as this module's `TConfig` — there is no + * separate build step, so `initialize` returns it unchanged. Registering this + * module *is* registering the provider; nothing here is special-cased by the + * real `azureIdentityModule`. + * + * @param auth - The provider to register. Defaults to a fresh {@link MockAuthProvider}. + * @returns The module descriptor, ready for {@link IModulesConfigurator.addConfig | addConfig}. + */ +export const createAuthMockModule = ( + auth: MockAuthProvider = new MockAuthProvider(), +): AuthMockModule => ({ + name: 'auth', + configure: () => auth, + initialize: async (args) => args.config, +}); + +/** + * Configuration callback for {@link enableAuthMock}. + */ +export type AuthMockConfigFn = (auth: MockAuthProvider, ref?: TRef) => void; + +/** + * Enables the `'auth'` module against a {@link MockAuthProvider}, so a test needs + * no credentials, no network, and no real Entra ID tenant. + * + * @remarks + * Registered last, this replaces whichever auth module the configurator already + * carries, so it works on a `FrameworkConfigurator` that pre-registers the real one. + * + * @param configurator - The modules configurator to register on. + * @param configure - Optional callback to seed the identity, token, or expiry. + * @returns The {@link MockAuthProvider} instance, for calling `login`/`logout` + * or its setters directly, in addition to whatever `configure` already did. + * + * @example + * ```typescript + * const auth = enableAuthMock(configurator, (auth) => { + * auth.setAccount({ username: 'ada@equinor.com' }); + * }); + * + * await auth.login({ request: { scopes: ['User.Read'] } }); + * ``` + */ +export const enableAuthMock = ( + // biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types + configurator: IModulesConfigurator, + configure?: AuthMockConfigFn, +): MockAuthProvider => { + const auth = new MockAuthProvider(); + configurator.addConfig({ module: createAuthMockModule(auth), configure }); + return auth; +}; diff --git a/packages/modules/azure-identity/tsconfig.json b/packages/modules/azure-identity/tsconfig.json index 384632da52..fdcbb48490 100644 --- a/packages/modules/azure-identity/tsconfig.json +++ b/packages/modules/azure-identity/tsconfig.json @@ -13,5 +13,5 @@ } ], "include": ["src/**/*"], - "exclude": ["dist", "node_modules"] + "exclude": ["dist", "node_modules", "src/__tests__/**/*"] } diff --git a/packages/modules/azure-identity/vitest.config.ts b/packages/modules/azure-identity/vitest.config.ts new file mode 100644 index 0000000000..f00c6cabd8 --- /dev/null +++ b/packages/modules/azure-identity/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineProject } from 'vitest/config'; + +import { name, version } from './package.json'; + +export default defineProject({ + test: { + environment: 'node', + include: ['src/__tests__/**/*.test.ts'], + name: `${name}@${version}`, + }, +}); diff --git a/packages/modules/msal-node/README.md b/packages/modules/msal-node/README.md index 30130d9ccf..fa84c82bfa 100644 --- a/packages/modules/msal-node/README.md +++ b/packages/modules/msal-node/README.md @@ -96,6 +96,36 @@ const token = await modules.auth.acquireAccessToken({ }); ``` +## Testing + +`token_only` mode already avoids real Azure AD traffic, but it is rigid: a single +fixed token, no expiry simulation, and `login`/`logout` always throw. For tests +that need to exercise sign-in, sign-out, or token-refresh/expiry logic, use +`MockAuthProvider` from the `/mock` subpath instead: + +```typescript +import { enableAuthMock } from '@equinor/fusion-framework-module-msal-node/mock'; + +// default identity, already signed in +const auth = enableAuthMock(configurator); + +// or seed the identity/state up front +enableAuthMock(configurator, (auth) => { + auth.setAccount({ username: 'ada@equinor.com', signedOut: true }); +}); + +// drive login/logout and inspect the resulting tokens directly +await auth.login({ request: { scopes: ['User.Read'] } }); +const token = await auth.acquireAccessToken({ request: { scopes: ['User.Read'] } }); + +// simulate an expired token, to exercise a consuming application's refresh path +auth.setExpiresOn(new Date(Date.now() - 1000)); +``` + +`MockAuthProvider` registers exactly like any other `IAuthProvider` implementation +— no special-cased wiring — and makes no real `@azure/msal-node` network calls, +opens no browser, and starts no local callback server. + ## Secure Token Storage When using `setClientConfig`, the module creates a `PublicClientApplication` backed by an encrypted persistence cache from `@azure/msal-node-extensions`: @@ -181,6 +211,15 @@ interface IAuthProvider { `login` and `logout` are only functional in interactive mode. In other modes they throw immediately. +### `/mock` subpath + +| Export | Description | +|---|---| +| `MockAuthProvider` | Configurable `IAuthProvider` test double — `login`/`logout` work, token and expiry are settable | +| `MockAuthProviderOptions` | Constructor options for `MockAuthProvider` | +| `enableAuthMock` | Registers a `MockAuthProvider` as the `'auth'` module and returns it | +| `createAuthMockModule` | Builds the mock module descriptor for a given `MockAuthProvider` | + ## Troubleshooting diff --git a/packages/modules/msal-node/package.json b/packages/modules/msal-node/package.json index 40a742129e..7d1c2d60f5 100644 --- a/packages/modules/msal-node/package.json +++ b/packages/modules/msal-node/package.json @@ -13,10 +13,28 @@ "./error": { "import": "./dist/esm/error.js", "types": "./dist/types/error.d.ts" + }, + "./mock": { + "import": "./dist/esm/mock/index.js", + "types": "./dist/types/mock/index.d.ts" + } + }, + "typesVersions": { + "*": { + ".": [ + "dist/types/index.d.ts" + ], + "error": [ + "dist/types/error.d.ts" + ], + "mock": [ + "dist/types/mock/index.d.ts" + ] } }, "scripts": { "build": "tsc -b", + "test": "vitest run", "prepack": "pnpm build" }, "keywords": [ @@ -45,6 +63,7 @@ "@azure/msal-node-extensions": "^5.0.2" }, "devDependencies": { - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vitest": "^4.1.0" } } diff --git a/packages/modules/msal-node/src/__tests__/mock/MockAuthProvider.test.ts b/packages/modules/msal-node/src/__tests__/mock/MockAuthProvider.test.ts new file mode 100644 index 0000000000..7879bbba38 --- /dev/null +++ b/packages/modules/msal-node/src/__tests__/mock/MockAuthProvider.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { ModulesConfigurator } from '@equinor/fusion-framework-module'; + +import { module as realModule } from '../../module'; +import type { IAuthProvider } from '../../AuthProvider.interface'; +import { createAuthMockModule, enableAuthMock, MockAuthProvider } from '../../mock'; + +describe('MockAuthProvider', () => { + it('is signed in with a default identity out of the box', async () => { + const auth = new MockAuthProvider(); + + const result = await auth.acquireToken({ request: { scopes: ['User.Read'] } }); + + expect(result.accessToken).toBeDefined(); + expect(result.expiresOn).toBeNull(); + expect(result.account?.username).toBe('test.user@equinor.com'); + }); + + it('throws from acquireToken/acquireAccessToken when constructed signedOut', async () => { + const auth = new MockAuthProvider({ signedOut: true }); + + await expect(auth.acquireToken({ request: { scopes: [] } })).rejects.toThrow(/not signed in/); + await expect(auth.acquireAccessToken({ request: { scopes: [] } })).rejects.toThrow( + /not signed in/, + ); + }); + + it('signs in on login, resolving the configured identity', async () => { + const auth = new MockAuthProvider({ + signedOut: true, + account: { username: 'ada@equinor.com' }, + }); + + const result = await auth.login({ request: { scopes: ['User.Read'] } }); + + expect(result.account?.username).toBe('ada@equinor.com'); + await expect( + auth.acquireAccessToken({ request: { scopes: ['User.Read'] } }), + ).resolves.toBeDefined(); + }); + + it('signs out on logout, without discarding the identity for a later login', async () => { + const auth = new MockAuthProvider(); + + await auth.logout(); + await expect(auth.acquireToken({ request: { scopes: [] } })).rejects.toThrow(/not signed in/); + + const result = await auth.login({ request: { scopes: [] } }); + expect(result.account?.username).toBe('test.user@equinor.com'); + }); + + it('setAccount merges identity fields and can flip the signed-in state', async () => { + const auth = new MockAuthProvider(); + + auth.setAccount({ username: 'ada@equinor.com', signedOut: true }); + + await expect(auth.acquireToken({ request: { scopes: [] } })).rejects.toThrow(/not signed in/); + + const result = await auth.login({ request: { scopes: [] } }); + expect(result.account?.username).toBe('ada@equinor.com'); + }); + + it('setAccessToken replaces the token returned by acquireToken/acquireAccessToken', async () => { + const auth = new MockAuthProvider(); + + auth.setAccessToken('a-different-token'); + + const result = await auth.acquireToken({ request: { scopes: [] } }); + expect(result.accessToken).toBe('a-different-token'); + await expect(auth.acquireAccessToken({ request: { scopes: [] } })).resolves.toBe( + 'a-different-token', + ); + }); + + it('setExpiresOn lets a test simulate an already-expired token', async () => { + const auth = new MockAuthProvider(); + const past = new Date(Date.now() - 1000); + + auth.setExpiresOn(past); + + const result = await auth.acquireToken({ request: { scopes: [] } }); + expect(result.expiresOn).toEqual(past); + expect(result.expiresOn?.getTime()).toBeLessThan(Date.now()); + }); +}); + +describe('createAuthMockModule', () => { + it('shares the real module name, so it is a drop-in replacement for the "auth" slot', () => { + const mod = createAuthMockModule(); + + expect(mod.name).toBe(realModule.name); + }); + + it('registers the given MockAuthProvider as the module config, unchanged', () => { + const auth = new MockAuthProvider({ account: { username: 'ada@equinor.com' } }); + const mod = createAuthMockModule(auth); + + expect(mod.configure?.()).toBe(auth); + }); +}); + +describe('enableAuthMock', () => { + it('initializes the "auth" module as the MockAuthProvider it registered', async () => { + const configurator = new ModulesConfigurator([]); + const auth = enableAuthMock(configurator); + + const instances = await configurator.initialize(); + + expect((instances as unknown as { auth: IAuthProvider }).auth).toBe(auth); + }); + + it('replaces an auth module that is already registered', async () => { + // A FrameworkConfigurator pre-registers the real auth module, so the mock is + // only useful if registering it afterwards wins + const configurator = new ModulesConfigurator([realModule]); + enableAuthMock(configurator, (auth) => auth.setAccount({ username: 'ada@equinor.com' })); + + const instances = await configurator.initialize(); + const result = await (instances as unknown as { auth: IAuthProvider }).auth.login({ + request: { scopes: [] }, + }); + + expect(result.account?.username).toBe('ada@equinor.com'); + }); +}); diff --git a/packages/modules/msal-node/src/mock/MockAuthProvider.ts b/packages/modules/msal-node/src/mock/MockAuthProvider.ts new file mode 100644 index 0000000000..a28ba81487 --- /dev/null +++ b/packages/modules/msal-node/src/mock/MockAuthProvider.ts @@ -0,0 +1,214 @@ +import type { AccountInfo, AuthenticationResult } from '@azure/msal-node'; + +import type { IAuthProvider } from '../AuthProvider.interface.js'; + +/** + * The identity {@link MockAuthProvider} signs in by default, and returns to + * when {@link MockAuthProvider.setAccount | setAccount} has not overridden it. + */ +const defaultAccount: AccountInfo = { + homeAccountId: 'fusion-mock-user.fusion-mock-tenant', + environment: 'login.microsoftonline.com', + tenantId: 'fusion-mock-tenant', + username: 'test.user@equinor.com', + localAccountId: 'fusion-mock-user', +}; + +/** + * Options for constructing a {@link MockAuthProvider}. + */ +export interface MockAuthProviderOptions { + /** Fields to merge onto the default identity (`test.user@equinor.com`). */ + account?: Partial; + /** + * Start without a signed-in session, while keeping the identity above. + * + * @remarks + * `acquireToken`/`acquireAccessToken` then throw until a test calls + * {@link MockAuthProvider.login | login}, which succeeds as that identity — + * this lets a test drive the sign-in journey rather than assert only on an + * already-signed-in end state. + */ + signedOut?: boolean; + /** Access token returned by {@link MockAuthProvider.acquireToken | acquireToken}. Defaults to a fixed placeholder string. */ + accessToken?: string; + /** Expiry reported by {@link MockAuthProvider.acquireToken | acquireToken}. Defaults to `null` (no known expiry), matching {@link AuthTokenProvider}. */ + expiresOn?: Date | null; +} + +/** + * Configurable {@link IAuthProvider} test double for the `'auth'` module slot. + * + * @remarks + * Unlike `AuthTokenProvider`'s single fixed token, `login`/`logout` actually + * change state here: a test can drive the provider from signed-out to signed-in + * (and back), and control the token and expiry `acquireToken`/`acquireAccessToken` + * return afterwards — including an expiry in the past, to exercise a consuming + * application's own refresh logic. + * + * `login`/`acquireToken` return a structurally valid `AuthenticationResult`, the + * same shape `@azure/msal-node` itself returns, so code that reads fields off the + * result (`account`, `scopes`, `expiresOn`, ...) behaves as it does in production. + * The token is **not** cryptographically valid and is rejected by any real service. + * + * No network calls are made; nothing here talks to Entra ID or `@azure/msal-node`. + * + * @example Default identity, already signed in + * ```typescript + * import { enableAuthMock } from '@equinor/fusion-framework-module-msal-node/mock'; + * + * const auth = enableAuthMock(configurator); + * await auth.acquireAccessToken({ request: { scopes: ['User.Read'] } }); // resolves + * ``` + * + * @example Start signed out, then log in + * ```typescript + * const auth = enableAuthMock(configurator, (auth) => auth.setAccount({ signedOut: true })); + * await expect(auth.acquireAccessToken({ request: { scopes } })).rejects.toThrow(); + * + * await auth.login({ request: { scopes } }); + * await auth.acquireAccessToken({ request: { scopes } }); // now resolves + * ``` + * + * @example Simulate an expired token + * ```typescript + * auth.setExpiresOn(new Date(Date.now() - 1000)); + * const { expiresOn } = await auth.acquireToken({ request: { scopes } }); + * expiresOn!.getTime() < Date.now(); // true — a refresh path under test now has something to react to + * ``` + */ +export class MockAuthProvider implements IAuthProvider { + #identity: AccountInfo; + #signedIn: boolean; + #accessToken: string; + #expiresOn: Date | null; + + /** + * @param options - The identity, initial signed-in state, token, and expiry to configure. + */ + constructor(options: MockAuthProviderOptions = {}) { + // `options.account` only overrides the fields a test names, keeping the rest of the default identity + this.#identity = { ...defaultAccount, ...options.account }; + this.#signedIn = !options.signedOut; + this.#accessToken = options.accessToken ?? 'fusion-mock-access-token'; + this.#expiresOn = options.expiresOn ?? null; + } + + /** + * Builds a structurally valid `AuthenticationResult` for the current identity and state. + * + * @param scopes - The scopes to embed in the result. + * @returns The synthesized `AuthenticationResult`. + */ + #createResult(scopes: string[]): AuthenticationResult { + return { + authority: `https://login.microsoftonline.com/${this.#identity.tenantId}`, + uniqueId: this.#identity.localAccountId, + tenantId: this.#identity.tenantId, + scopes, + account: this.#identity, + idToken: '', + idTokenClaims: {}, + accessToken: this.#accessToken, + fromCache: false, + expiresOn: this.#expiresOn, + tokenType: 'Bearer', + correlationId: 'fusion-mock-correlation-id', + }; + } + + /** + * Signs the configured identity in. + * + * Unlike `AuthTokenProvider.login`, this succeeds — moving the provider into + * a signed-in state so subsequent `acquireToken`/`acquireAccessToken` calls + * resolve. + * + * @param options - The scopes to embed in the returned `AuthenticationResult`. + * @returns The signed-in `AuthenticationResult`. + */ + async login(options: { request: { scopes: string[] } }): Promise { + this.#signedIn = true; + return this.#createResult(options.request.scopes); + } + + /** + * Signs out, without discarding the configured identity — a later {@link login} + * signs the same identity back in. + */ + async logout(): Promise { + this.#signedIn = false; + } + + /** + * Returns the configured token and expiry for the signed-in identity. + * + * @param options - The scopes to embed in the returned `AuthenticationResult`. + * @returns The `AuthenticationResult`. + * @throws When not signed in — call {@link login} first, or construct without `signedOut`. + */ + async acquireToken(options: { request: { scopes: string[] } }): Promise { + // Mirrors a real provider's failure mode when there is no session to acquire a token for + if (!this.#signedIn) { + throw new Error( + 'MockAuthProvider: not signed in — call login() first, or construct without `signedOut`', + ); + } + return this.#createResult(options.request.scopes); + } + + /** + * Returns the configured access token for the signed-in identity. + * + * @param options - The scopes to embed in the underlying `AuthenticationResult`. + * @returns The access token string. + * @throws When not signed in — call {@link login} first, or construct without `signedOut`. + */ + async acquireAccessToken(options: { + request: { scopes: string[] }; + interactive?: boolean; + }): Promise { + const { accessToken } = await this.acquireToken(options); + return accessToken; + } + + /** + * Replaces (parts of) the signed-in identity, and/or the signed-in state, + * without going through {@link login}/{@link logout}. + * + * @param account - Fields to merge onto the current identity, plus an optional `signedOut` flag. + * @returns `this`, for chaining. + */ + setAccount(account: Partial & { signedOut?: boolean }): this { + const { signedOut, ...identity } = account; + // Only the fields the caller named are replaced; the rest of the current identity is kept + this.#identity = { ...this.#identity, ...identity }; + // `undefined` means "leave the signed-in state as it is", distinct from explicitly passing `false` + if (signedOut !== undefined) { + this.#signedIn = !signedOut; + } + return this; + } + + /** + * Replaces the access token returned by {@link acquireToken}/{@link acquireAccessToken}. + * + * @param accessToken - The new access token string. + * @returns `this`, for chaining. + */ + setAccessToken(accessToken: string): this { + this.#accessToken = accessToken; + return this; + } + + /** + * Replaces the expiry reported by {@link acquireToken}. + * + * @param expiresOn - The new expiry, or `null` to report no known expiry. + * @returns `this`, for chaining. Pass a date in the past to simulate an expired token. + */ + setExpiresOn(expiresOn: Date | null): this { + this.#expiresOn = expiresOn; + return this; + } +} diff --git a/packages/modules/msal-node/src/mock/index.ts b/packages/modules/msal-node/src/mock/index.ts new file mode 100644 index 0000000000..4cd77acc50 --- /dev/null +++ b/packages/modules/msal-node/src/mock/index.ts @@ -0,0 +1,32 @@ +/** + * Configurable {@link MockAuthProvider | IAuthProvider} test double for + * `@equinor/fusion-framework-module-msal-node`. + * + * @remarks + * Unlike `token_only` mode's single fixed token, `login`/`logout` actually + * change state, and the returned token and expiry are both configurable — + * so a test can exercise sign-in, sign-out, and token-refresh/expiry paths + * without a real Entra ID tenant, browser, or device-code flow. + * + * @example + * ```typescript + * import { enableAuthMock } from '@equinor/fusion-framework-module-msal-node/mock'; + * + * // default identity, already signed in + * const auth = enableAuthMock(configurator); + * + * // or seed the identity/state up front + * enableAuthMock(configurator, (auth) => { + * auth.setAccount({ username: 'ada@equinor.com', signedOut: true }); + * }); + * ``` + * + * @packageDocumentation + */ +export { MockAuthProvider, type MockAuthProviderOptions } from './MockAuthProvider.js'; +export { + createAuthMockModule, + enableAuthMock, + type AuthMockModule, + type AuthMockConfigFn, +} from './module.js'; diff --git a/packages/modules/msal-node/src/mock/module.ts b/packages/modules/msal-node/src/mock/module.ts new file mode 100644 index 0000000000..5cf4ded4f9 --- /dev/null +++ b/packages/modules/msal-node/src/mock/module.ts @@ -0,0 +1,67 @@ +import type { IModulesConfigurator, Module } from '@equinor/fusion-framework-module'; + +import type { IAuthProvider } from '../AuthProvider.interface.js'; + +import { MockAuthProvider } from './MockAuthProvider.js'; + +/** + * The `'auth'` module backed by a {@link MockAuthProvider} instead of a real credential. + */ +export type AuthMockModule = Module<'auth', IAuthProvider, MockAuthProvider>; + +/** + * Builds the `'auth'` module descriptor for a given {@link MockAuthProvider}. + * + * @remarks + * `configure` returns `auth` directly as this module's `TConfig` — there is no + * separate build step, so `initialize` returns it unchanged. Registering this + * module *is* registering the provider; nothing here is special-cased by the + * real `authModule`. + * + * @param auth - The provider to register. Defaults to a fresh {@link MockAuthProvider}. + * @returns The module descriptor, ready for {@link IModulesConfigurator.addConfig | addConfig}. + */ +export const createAuthMockModule = ( + auth: MockAuthProvider = new MockAuthProvider(), +): AuthMockModule => ({ + name: 'auth', + configure: () => auth, + initialize: async (args) => args.config, +}); + +/** + * Configuration callback for {@link enableAuthMock}. + */ +export type AuthMockConfigFn = (auth: MockAuthProvider, ref?: TRef) => void; + +/** + * Enables the `'auth'` module against a {@link MockAuthProvider}, so a test needs + * no credentials, no network, and no local callback server or device-code flow. + * + * @remarks + * Registered last, this replaces whichever auth module the configurator already + * carries, so it works on a `FrameworkConfigurator` that pre-registers the real one. + * + * @param configurator - The modules configurator to register on. + * @param configure - Optional callback to seed the identity, token, or expiry. + * @returns The {@link MockAuthProvider} instance, for calling `login`/`logout` + * or its setters directly, in addition to whatever `configure` already did. + * + * @example + * ```typescript + * const auth = enableAuthMock(configurator, (auth) => { + * auth.setAccount({ username: 'ada@equinor.com' }); + * }); + * + * await auth.login({ request: { scopes: ['User.Read'] } }); + * ``` + */ +export const enableAuthMock = ( + // biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types + configurator: IModulesConfigurator, + configure?: AuthMockConfigFn, +): MockAuthProvider => { + const auth = new MockAuthProvider(); + configurator.addConfig({ module: createAuthMockModule(auth), configure }); + return auth; +}; diff --git a/packages/modules/msal-node/tsconfig.json b/packages/modules/msal-node/tsconfig.json index 6304992d84..2a993ed91c 100644 --- a/packages/modules/msal-node/tsconfig.json +++ b/packages/modules/msal-node/tsconfig.json @@ -13,5 +13,5 @@ } ], "include": ["src/**/*"], - "exclude": ["node_modules", "lib"] + "exclude": ["node_modules", "lib", "src/__tests__/**/*"] } diff --git a/packages/modules/msal-node/vitest.config.ts b/packages/modules/msal-node/vitest.config.ts new file mode 100644 index 0000000000..f00c6cabd8 --- /dev/null +++ b/packages/modules/msal-node/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineProject } from 'vitest/config'; + +import { name, version } from './package.json'; + +export default defineProject({ + test: { + environment: 'node', + include: ['src/__tests__/**/*.test.ts'], + name: `${name}@${version}`, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e11abace6..56e1b74d44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,7 +96,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@24.12.2)(typescript@7.0.2))(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@24.12.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1) cookbooks/app-react: devDependencies: @@ -1121,7 +1121,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/cli-plugins/ai-base: dependencies: @@ -1149,7 +1149,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/cli-plugins/ai-chat: dependencies: @@ -1180,7 +1180,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/cli-plugins/ai-index: dependencies: @@ -1250,7 +1250,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/cli-plugins/copilot: dependencies: @@ -1420,7 +1420,7 @@ importers: version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/framework: dependencies: @@ -1457,7 +1457,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/linting/cli: dependencies: @@ -1513,7 +1513,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/linting/lsp: dependencies: @@ -1554,7 +1554,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/linting/vscode: dependencies: @@ -1637,7 +1637,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/modules/analytics: dependencies: @@ -1695,7 +1695,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/modules/app: dependencies: @@ -1752,6 +1752,9 @@ importers: typescript: specifier: ^7.0.2 version: 7.0.2 + vitest: + specifier: ^4.1.0 + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) optionalDependencies: '@azure/identity-cache-persistence': specifier: ^1.3.0 @@ -1904,7 +1907,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/modules/module: dependencies: @@ -1923,7 +1926,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/modules/msal: dependencies: @@ -1965,6 +1968,9 @@ importers: typescript: specifier: ^7.0.2 version: 7.0.2 + vitest: + specifier: ^4.1.0 + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) optionalDependencies: '@azure/msal-node-extensions': specifier: ^5.0.2 @@ -2005,7 +2011,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/modules/service-discovery: dependencies: @@ -2058,7 +2064,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/modules/signalr: dependencies: @@ -2111,7 +2117,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/modules/widget: dependencies: @@ -2262,7 +2268,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/react/components/bookmark: dependencies: @@ -2314,7 +2320,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/react/components/people-resolver: dependencies: @@ -2613,7 +2619,7 @@ importers: version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/utils/imports: dependencies: @@ -2638,7 +2644,7 @@ importers: version: 4.6.0 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/utils/load-env: dependencies: @@ -2654,7 +2660,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/utils/log: dependencies: @@ -2670,7 +2676,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/utils/observable: dependencies: @@ -2707,7 +2713,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/utils/openapi-mock: dependencies: @@ -2729,7 +2735,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/utils/query: dependencies: @@ -2760,7 +2766,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/vite-plugins/api-service: dependencies: @@ -2788,7 +2794,7 @@ importers: version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/vite-plugins/markdown: devDependencies: @@ -2821,7 +2827,7 @@ importers: version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) vitest: specifier: ^4.1.0 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) packages/vite-plugins/routes-dsl: devDependencies: @@ -11329,7 +11335,6 @@ packages: '@vitest/ui': 4.1.4 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -16032,7 +16037,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1) '@vitest/expect@3.2.7': dependencies: @@ -16069,7 +16074,7 @@ snapshots: msw: 2.15.0(@types/node@24.12.2)(typescript@7.0.2) vite: 8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1) - '@vitest/mocker@4.1.4(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1))': + '@vitest/mocker@4.1.4(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 @@ -21011,7 +21016,7 @@ snapshots: - tsx - yaml - vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@24.12.2)(typescript@7.0.2))(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)): + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@24.12.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1): dependencies: '@vitest/expect': 4.1.4 '@vitest/mocker': 4.1.4(msw@2.15.0(@types/node@24.12.2)(typescript@7.0.2))(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) @@ -21040,12 +21045,23 @@ snapshots: happy-dom: 20.11.1 jsdom: 30.0.1 transitivePeerDependencies: + - '@vitejs/devtools' + - esbuild + - jiti + - less - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml - vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)): + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + '@vitest/mocker': 4.1.4(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -21071,7 +21087,60 @@ snapshots: happy-dom: 20.11.1 jsdom: 30.0.1 transitivePeerDependencies: + - '@vitejs/devtools' + - esbuild + - jiti + - less - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(esbuild@0.28.1)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.1.2 + '@vitest/coverage-v8': 4.1.4(vitest@4.1.4) + happy-dom: 20.11.1 + jsdom: 30.0.1 + transitivePeerDependencies: + - '@vitejs/devtools' + - esbuild + - jiti + - less + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml vscode-jsonrpc@8.2.1(patch_hash=d78796f2e6ae0a6c190cb4da47138eafba7404e190b66f5ac22b382e16f04eca): {} From 6f31c73a2a8ba51933ab56205b90fef288398117 Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Mon, 10 Aug 2026 09:07:20 +0200 Subject: [PATCH 2/3] fix(azure-identity,msal-node): make enableAuthMock generic over TModules/TRef Replaces the IModulesConfigurator parameter with a generic = Array, TRef = unknown> signature, so the optional configure callback keeps the caller's actual TRef type instead of erasing it to unknown. Addresses review feedback on PR #5244 (copilot-pull-request-reviewer). --- packages/modules/azure-identity/src/mock/module.ts | 14 +++++++++----- packages/modules/msal-node/src/mock/module.ts | 14 +++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/modules/azure-identity/src/mock/module.ts b/packages/modules/azure-identity/src/mock/module.ts index a7a6504962..a8a45f7a8c 100644 --- a/packages/modules/azure-identity/src/mock/module.ts +++ b/packages/modules/azure-identity/src/mock/module.ts @@ -1,4 +1,4 @@ -import type { IModulesConfigurator, Module } from '@equinor/fusion-framework-module'; +import type { AnyModule, IModulesConfigurator, Module } from '@equinor/fusion-framework-module'; import type { IAuthProvider } from '../AuthProvider.interface.js'; @@ -46,6 +46,8 @@ export type AuthMockConfigFn = (auth: MockAuthProvider, ref?: TR * @param configure - Optional callback to seed the identity, token, or expiry. * @returns The {@link MockAuthProvider} instance, for calling `login`/`logout` * or its setters directly, in addition to whatever `configure` already did. + * @template TModules - The array of module descriptors managed by `configurator`. + * @template TRef - Reference type forwarded to `configure`, inferred from `configurator`. * * @example * ```typescript @@ -56,10 +58,12 @@ export type AuthMockConfigFn = (auth: MockAuthProvider, ref?: TR * await auth.login({ request: { scopes: ['User.Read'] } }); * ``` */ -export const enableAuthMock = ( - // biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types - configurator: IModulesConfigurator, - configure?: AuthMockConfigFn, +export const enableAuthMock = < + TModules extends Array = Array, + TRef = unknown, +>( + configurator: IModulesConfigurator, + configure?: AuthMockConfigFn, ): MockAuthProvider => { const auth = new MockAuthProvider(); configurator.addConfig({ module: createAuthMockModule(auth), configure }); diff --git a/packages/modules/msal-node/src/mock/module.ts b/packages/modules/msal-node/src/mock/module.ts index 5cf4ded4f9..2b138ff115 100644 --- a/packages/modules/msal-node/src/mock/module.ts +++ b/packages/modules/msal-node/src/mock/module.ts @@ -1,4 +1,4 @@ -import type { IModulesConfigurator, Module } from '@equinor/fusion-framework-module'; +import type { AnyModule, IModulesConfigurator, Module } from '@equinor/fusion-framework-module'; import type { IAuthProvider } from '../AuthProvider.interface.js'; @@ -46,6 +46,8 @@ export type AuthMockConfigFn = (auth: MockAuthProvider, ref?: TR * @param configure - Optional callback to seed the identity, token, or expiry. * @returns The {@link MockAuthProvider} instance, for calling `login`/`logout` * or its setters directly, in addition to whatever `configure` already did. + * @template TModules - The array of module descriptors managed by `configurator`. + * @template TRef - Reference type forwarded to `configure`, inferred from `configurator`. * * @example * ```typescript @@ -56,10 +58,12 @@ export type AuthMockConfigFn = (auth: MockAuthProvider, ref?: TR * await auth.login({ request: { scopes: ['User.Read'] } }); * ``` */ -export const enableAuthMock = ( - // biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types - configurator: IModulesConfigurator, - configure?: AuthMockConfigFn, +export const enableAuthMock = < + TModules extends Array = Array, + TRef = unknown, +>( + configurator: IModulesConfigurator, + configure?: AuthMockConfigFn, ): MockAuthProvider => { const auth = new MockAuthProvider(); configurator.addConfig({ module: createAuthMockModule(auth), configure }); From f71785c83a5a739113e2935877fd118e317ab213 Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Mon, 10 Aug 2026 09:07:28 +0200 Subject: [PATCH 3/3] fix(azure-identity,msal-node): fix double-registration in mock README example The Testing section called enableAuthMock(configurator) once for its return value, then again with a configure callback to seed the account. The second call replaces the first provider (enableAuthMock always registers last), so the retained `auth` reference from the first call was no longer the active provider by the time login/acquireAccessToken ran against it. Collapse to a single enableAuthMock call that both seeds the account and returns the provider that stays registered. Addresses review feedback on PR #5244 (copilot-pull-request-reviewer). --- packages/modules/azure-identity/README.md | 7 ++----- packages/modules/msal-node/README.md | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/modules/azure-identity/README.md b/packages/modules/azure-identity/README.md index aea2b84f4d..23f5b1da9c 100644 --- a/packages/modules/azure-identity/README.md +++ b/packages/modules/azure-identity/README.md @@ -123,11 +123,8 @@ that need to exercise sign-in, sign-out, or token-refresh/expiry logic, use ```typescript import { enableAuthMock } from '@equinor/fusion-framework-module-azure-identity/mock'; -// default identity, already signed in -const auth = enableAuthMock(configurator); - -// or seed the identity/state up front -enableAuthMock(configurator, (auth) => { +// seed the identity/state up front; omit the callback for a default, already-signed-in identity +const auth = enableAuthMock(configurator, (auth) => { auth.setAccount({ username: 'ada@equinor.com', signedOut: true }); }); diff --git a/packages/modules/msal-node/README.md b/packages/modules/msal-node/README.md index fa84c82bfa..defedc3bee 100644 --- a/packages/modules/msal-node/README.md +++ b/packages/modules/msal-node/README.md @@ -106,11 +106,8 @@ that need to exercise sign-in, sign-out, or token-refresh/expiry logic, use ```typescript import { enableAuthMock } from '@equinor/fusion-framework-module-msal-node/mock'; -// default identity, already signed in -const auth = enableAuthMock(configurator); - -// or seed the identity/state up front -enableAuthMock(configurator, (auth) => { +// seed the identity/state up front; omit the callback for a default, already-signed-in identity +const auth = enableAuthMock(configurator, (auth) => { auth.setAccount({ username: 'ada@equinor.com', signedOut: true }); });