diff --git a/.changeset/docs-restructure.md b/.changeset/docs-restructure.md new file mode 100644 index 0000000000..58399adefe --- /dev/null +++ b/.changeset/docs-restructure.md @@ -0,0 +1,15 @@ +--- +"@equinor/fusion-framework-module-service-discovery": patch +"@equinor/fusion-framework-module-msal": patch +"@equinor/fusion-framework": minor +--- + +Restructure documentation so each README is an entry point rather than a manual. + +Long-form content moved into per-package `docs/` folders, matching the convention already used by `@equinor/fusion-framework-module` and `@equinor/fusion-framework-module-http`. Each README now keeps the elevator pitch, the shortest working example and a documentation table linking to the rest. + +- **msal** — `docs/api-reference.md`, `docs/auth-code-flow.md`, `docs/testing.md`, `docs/version-management.md`, `docs/migration-v2-to-v4.md`, `docs/troubleshooting.md`. The README also gained the top-level heading it was missing. +- **service-discovery** — `docs/configuration.md`, `docs/testing.md`, `docs/session-overrides.md`, `docs/api-reference.md`. +- **framework** — `docs/testing.md`, `docs/testing-design.md`, `docs/testing-extending.md`, `docs/testing-api.md`. + +Both module READMEs now document their `/mock` entry point, which was previously undocumented, and state that spying on an individual call is the test runner's job rather than something these packages provide. diff --git a/.changeset/dot-path-optional-branches.md b/.changeset/dot-path-optional-branches.md new file mode 100644 index 0000000000..7a5a48cde1 --- /dev/null +++ b/.changeset/dot-path-optional-branches.md @@ -0,0 +1,9 @@ +--- +"@equinor/fusion-framework-module": patch +--- + +Fix `DotPath` skipping over optional object properties, which made anything beneath them unreachable from `BaseConfigBuilder._set`. + +An optional property is typed `T | undefined`, which does not extend `object`, so the path union stopped at the property itself: given `{ foo?: { bar: string } }`, `'foo'` was allowed but `'foo.bar'` was not. `DotPathType` already unwrapped such properties with `NonNullable`, so the two disagreed — a path it could resolve was one `_set` refused. + +`DotPath` now unwraps the same way. This only widens the accepted union, so existing calls are unaffected. diff --git a/.changeset/framework-init-without-dom.md b/.changeset/framework-init-without-dom.md new file mode 100644 index 0000000000..d5bc504783 --- /dev/null +++ b/.changeset/framework-init-without-dom.md @@ -0,0 +1,9 @@ +--- +"@equinor/fusion-framework": patch +--- + +`init` no longer throws when no DOM is present. + +The running instance is published as `window.Fusion` for portal shells and widgets. That assignment was unguarded, so initializing the framework anywhere without a `window` — a test runner using the `node` environment, or a server-side render — failed with `ReferenceError: window is not defined`. + +The assignment is now skipped when `window` is undefined. Browser behaviour is unchanged. diff --git a/.changeset/framework-mock-configurator-pin.md b/.changeset/framework-mock-configurator-pin.md new file mode 100644 index 0000000000..3b7c32da98 --- /dev/null +++ b/.changeset/framework-mock-configurator-pin.md @@ -0,0 +1,24 @@ +--- +"@equinor/fusion-framework": minor +--- + +Add `_pin` and `_getConfig` to `FrameworkMockConfigurator`, so a module supplied through `TModules` can get the same kind of named accessor `.msal` and `.serviceDiscovery` already have. + +Previously that pinning was hand-written twice, once per built-in mock, with no way for anything else to do the same. A subclass now reuses it directly: + +```typescript +class AppMockConfigurator extends FrameworkMockConfigurator<[InvoiceModule]> { + constructor() { + super(); + this._pin(invoiceMockModule); + } + + get invoices(): InvoiceMockConfigurator { + return this._getConfig('invoices'); + } +} +``` + +`_pin(module)` replaces the module's own `configure` factory with one that always returns the same instance, and registers it — pinning it before initialization runs is what lets a test reach the accessor synchronously and have it be the configurator the module is actually built from. `_getConfig(name)` looks that instance up by the module's name, throwing if nothing was pinned for it. + +`.msal` and `.serviceDiscovery` are unchanged for consumers; they are now built from `_pin`/`_getConfig` themselves rather than from two private fields. diff --git a/.changeset/framework-mock-configurator-remaining-modules.md b/.changeset/framework-mock-configurator-remaining-modules.md new file mode 100644 index 0000000000..67167c29c5 --- /dev/null +++ b/.changeset/framework-mock-configurator-remaining-modules.md @@ -0,0 +1,16 @@ +--- +"@equinor/fusion-framework": minor +--- + +Add `.services`, `.context` and `.telemetry` accessors to `FrameworkMockConfigurator`, alongside the existing `.msal`, `.serviceDiscovery` and `.http`. + +These three modules have no test double yet, so anything read through `.services`, `.context` or `.telemetry` still performs real I/O — but their configurators are now reachable synchronously the same way `.msal` and `.serviceDiscovery` already are, since none of their `configure` factories depend on `ref`: + +```typescript +const configurator = new FrameworkMockConfigurator(); +configurator.services.configureClient('my-api', { baseUri: 'http://localhost:6669' }); + +const fusion = await init(configurator); +``` + +`event` is intentionally left out: its `configure` factory reads `ref` to wire event bubbling to a parent event provider when `FrameworkMockConfigurator` is hoisted inside a host framework, and pinning it would call `configure()` with `ref` always `undefined` — silently disabling that bubbling. diff --git a/.changeset/framework-mock-entry-point.md b/.changeset/framework-mock-entry-point.md new file mode 100644 index 0000000000..6eaa170c2b --- /dev/null +++ b/.changeset/framework-mock-entry-point.md @@ -0,0 +1,37 @@ +--- +"@equinor/fusion-framework": minor +--- + +Add a `./mock` entry point for initializing the framework in a test. + +```typescript +import { mockFramework } from '@equinor/fusion-framework/mock'; + +const fusion = await mockFramework(); +``` + +`mockFramework` runs the real configure → initialize pipeline with the real built-in modules and substitutes only the boundaries that leave the process — the MSAL client and the service discovery client. Module wiring, configuration validation and lifecycle hooks behave as they do in production, so a test still catches wiring mistakes that a hand-built replacement for the module graph would hide. + +It takes a single callback receiving a `FrameworkMockConfigurator`, which **is** a `FrameworkConfigurator`. Modules whose boundary is mocked expose their own configurator as a property, so a test configures them without registering a callback: + +```typescript +const fusion = await mockFramework((configurator) => { + configurator.msal.setAccount({ name: 'Ada Lovelace' }); + configurator.serviceDiscovery.setBaseUri('http://localhost:6669'); + configurator.serviceDiscovery.addService({ key: 'my-api' }); +}); +``` + +Because it is a real configurator, every `enableX` helper an application already uses accepts it unchanged — including the ones a team writes for their own modules. Those modules can be passed as a type argument so they are typed on the configurator *and* on the resulting `fusion.modules` without a cast: + +```typescript +const fusion = await mockFramework<[InvoiceModule]>((configurator) => { + enableInvoicesMock(configurator, { total: 42 }); +}); + +await fusion.modules.invoices.getInvoice('inv-1'); // typed +``` + +`FrameworkMockConfigurator` is also exported for tests that need to hold on to the configurator and call `init` themselves. + +The entry point owns no mock logic. Each module exports its own test double from its own `./mock` entry point, and this one composes the built-in set; an application module follows the same pattern and plugs in without any support from this package. It has no test-runner dependency and provides no mocking API, because replacing an individual call belongs to your test runner. diff --git a/.changeset/module-configurator-callback-replacement.md b/.changeset/module-configurator-callback-replacement.md new file mode 100644 index 0000000000..865ddbf66a --- /dev/null +++ b/.changeset/module-configurator-callback-replacement.md @@ -0,0 +1,5 @@ +--- +"@equinor/fusion-framework-module": patch +--- + +Ensure module re-registration replaces prior `configure`, `afterConfig`, and `afterInit` callbacks for modules with the same name. This prevents stale callback execution when mock modules like `enableMsalMock` override a real module registration. diff --git a/.changeset/module-http_mock-entry-point.md b/.changeset/module-http_mock-entry-point.md new file mode 100644 index 0000000000..ba9093015d --- /dev/null +++ b/.changeset/module-http_mock-entry-point.md @@ -0,0 +1,23 @@ +--- +"@equinor/fusion-framework-module-http": minor +--- + +Add a `mock` entry point (`@equinor/fusion-framework-module-http/mock`) so HTTP clients answer requests from registered route handlers instead of the network. + +```typescript +import { enableHttpMock } from '@equinor/fusion-framework-module-http/mock'; + +enableHttpMock(configurator, (builder) => { + builder.configureClient('catalog', { baseUri: 'https://api.example.com' }); + builder.get('/items', () => Response.json([{ id: 1 }])); +}); +``` + +Route handlers are Fetch-standard middleware — `(request: Request) => Response | undefined | Promise<...>` — tried in registration order, with `undefined` falling through to the next one. One router is shared across every named client a `HttpMockConfigurator` builds, matched against each request's fully resolved URL. + +Two adapters drop in an existing backend with no hard dependency on it: + +- `fromExpressStyleHandler(handler)` — adapts an Express-style `(req, res)` handler (or a whole framework built from them, like `openapi-backend`). +- `fromOpenApiMock(openApiMock)` — adapts an `@equinor/fusion-openapi-mock` instance, so a real `openapi.json`/`openapi.yaml` fakes every response with no handlers written at all. + +`@equinor/fusion-framework`'s `FrameworkMockConfigurator` now pins this instead of the real HTTP module, so `.http` on a mocked framework never reaches the network either. diff --git a/.changeset/module_configurator-fix.md b/.changeset/module_configurator-fix.md new file mode 100644 index 0000000000..917c943741 --- /dev/null +++ b/.changeset/module_configurator-fix.md @@ -0,0 +1,9 @@ +--- +"@equinor/fusion-framework-module": patch +--- + +Fix a bug in the module configurator that caused configurator phases (configure / post-initialize / dispose) to run out of order or skip post-configure hooks in certain initialization paths. + +This ensures module configuration and plugin hooks run reliably during module initialization, preventing missed setup steps for consumer modules. + +Fixes: restores correct configurator phase ordering and prevents lost initialization for modules that rely on post-configure hooks. diff --git a/.changeset/msal-config-schema-module.md b/.changeset/msal-config-schema-module.md new file mode 100644 index 0000000000..33f26fa4ef --- /dev/null +++ b/.changeset/msal-config-schema-module.md @@ -0,0 +1,19 @@ +--- +"@equinor/fusion-framework-module-msal": minor +--- + +Move the MSAL configuration schema into `MsalConfig.schema.ts` and add `MsalConfigExtension`, an extension point for variants of this module. + +`MsalConfig` is now the schema's inferred type intersected with `MsalConfigExtension`, an empty interface a variant merges its own configuration into: + +```typescript +declare module '@equinor/fusion-framework-module-msal' { + interface MsalConfigExtension { + mock?: { account?: MsalMockUser }; + } +} +``` + +`BaseConfigBuilder._set` derives its target from `MsalConfig`, so before this a key the type did not know about could only be set by casting past the builder. Making the configurator generic over its configuration cannot solve that: a dot-path union over an unresolved type parameter defers, which takes every existing literal path down with it. + +The schema is unchanged and still describes exactly what reaches `MsalProvider` — it strips anything merged in, so an extension carries a declaration across the builder and stops there. `MsalConfigSchema`, `TelemetryConfigSchema` and their types are re-exported from `MsalConfigurator` as before. diff --git a/.changeset/msal-create-client-seam.md b/.changeset/msal-create-client-seam.md new file mode 100644 index 0000000000..9ad2d071ff --- /dev/null +++ b/.changeset/msal-create-client-seam.md @@ -0,0 +1,28 @@ +--- +"@equinor/fusion-framework-module-msal": minor +--- + +Extract client construction from `MsalConfigurator._processConfig` into overridable seams. + +`_processConfig` now only decides *whether* a client is needed; `protected _createClient(config, init)` decides *what* to build, and `protected _createClientConfig(config)` resolves the `MsalClientConfig` it is built from — authority derived from the tenant, cache location, telemetry-backed logging and cache lookup policy included. + +Behaviour is unchanged for consumers: the client is still auto-created from `setClientConfig`, and a client supplied through `setClient` still wins, because `_createClient` is consulted only when no client was set. + +This gives a supported seam for authenticating through something other than Entra ID: + +```typescript +class MyConfigurator extends MsalConfigurator { + protected override async _createClient(config: MsalConfig): Promise { + // same fully resolved configuration the real client is built from + return new MyOwnMsalClient(this._createClientConfig(config)); + } +} +``` + +Overriding it replaces only the client, leaving the builder, the schema validation and `MsalProvider` untouched. + +No client is built when the module is hoisted onto a host application's provider — an app running inside a portal authenticates through the host, so a client built during configuration would be discarded, or worse, shadow the host's signed-in user. `protected _isHoisted(init)` exposes that decision to subclasses. + +Also adds `getClientConfig()`, the counterpart to `setClientConfig`, so a subclass can tell "nothing was declared" apart from "declared, and here it is". + +`_createClientConfig` applies its defaults to a copy of the declared configuration, not the object itself, so a caller reusing or asserting on it never sees it rewritten, and a shared constant can be used as a default without one configurator's client contaminating the next. diff --git a/.changeset/msal-mock-account-cache.md b/.changeset/msal-mock-account-cache.md new file mode 100644 index 0000000000..9545ea6034 --- /dev/null +++ b/.changeset/msal-mock-account-cache.md @@ -0,0 +1,22 @@ +--- +"@equinor/fusion-framework-module-msal": minor +--- + +Give `MsalMockClient` a real account cache, so its account APIs agree with each other and with MSAL. + +Previously the client held a single nullable account, which made its surface inconsistent: `getAccount(filter)` ignored the filter, `getAllAccounts()` reported an account even after one had only been made active, and signing out merely blanked a field. + +The client now keeps a cache keyed by `homeAccountId` alongside an active account, matching MSAL: + +- `getAccount(filter)` matches on `homeAccountId`, `localAccountId`, `username` and `tenantId`. +- `getAllAccounts()` returns everything cached. +- Signing in adds the account and activates it; signing out removes it, rather than leaving a stale entry behind. +- `setUser` replaces the session, so declaring a second user never leaves two accounts cached. + +`setActiveAccount` deliberately departs from MSAL in one respect: an account that was never issued by a sign-in is accepted and added to the cache. That makes swapping the user between tests a single line, without rebuilding the framework: + +```typescript +beforeEach(() => { + fusion.modules.auth.client.setActiveAccount(account); +}); +``` diff --git a/.changeset/msal-mock-entry-point.md b/.changeset/msal-mock-entry-point.md new file mode 100644 index 0000000000..2499aa6076 --- /dev/null +++ b/.changeset/msal-mock-entry-point.md @@ -0,0 +1,31 @@ +--- +"@equinor/fusion-framework-module-msal": minor +--- + +Added a `./mock` entry point so applications can run against the auth module without credentials or network access. + +```ts +import { enableMsalMock, createMsalMockClient } from '@equinor/fusion-framework-module-msal/mock'; + +// default mock user +enableMsalMock(configurator); + +// or a specific one +enableMsalMock(configurator, (builder) => { + builder.setAccount({ name: 'Ada Lovelace' }); +}); +``` + +Only the MSAL **client** is substituted. `MsalMockClient` resolves tokens in-process and takes the same `MsalClientConfig` as the real `MsalClient`, so `setClientConfig` means the same thing whether a test runs against Entra ID or in-process. `MsalMockConfigurator` builds it through the `_createClient` seam, and `msalMockModule` differs from the real module in its `configure` alone — `initialize` is the production one, untouched, so `MsalProvider`, schema validation and the whole start-up path run exactly as they do in production. `IMsalProvider` and `MsalConfigurator` are untouched. + +The same client works with the plain module, without the mock module: + +```ts +enableMSAL(configurator, (builder) => + builder.setClient(createMsalMockClient({ auth: { clientId: 'my-app' } }, { name: 'Ada Lovelace' })), +); +``` + +Tokens are structurally valid, unsigned JWTs and are identical between runs. They are not cryptographically valid and are rejected by any real service. + +Exports `enableMsalMock`, `msalMockModule`, `MsalMockConfigurator`, `MsalMockClient`, `createMsalMockClient` and `createMockToken`. The entry point has no test-runner dependency. diff --git a/.changeset/msal-mock-set-account.md b/.changeset/msal-mock-set-account.md new file mode 100644 index 0000000000..619e9b4c41 --- /dev/null +++ b/.changeset/msal-mock-set-account.md @@ -0,0 +1,34 @@ +--- +"@equinor/fusion-framework-module-msal": minor +--- + +Add `setAccount` to `MsalMockConfigurator`, so a test declares the signed-in user on the builder instead of baking it into a client. + +```typescript +enableMsalMock(configurator, (builder) => { + builder.setAccount({ name: 'Ada Lovelace', username: 'ada@equinor.com' }); +}); +``` + +It takes an object, `null` when nobody is signed in, or an ordinary `ConfigBuilderCallback` resolving either: + +```typescript +builder.setAccount(null); +builder.setAccount(async ({ hasModule }) => ({ + name: hasModule('app') ? 'App User' : 'Portal User', +})); +``` + +The callback is the builder's own type rather than a bespoke one, so it is handed the same arguments every other configuration callback receives and is resolved by the same machinery. + +`null` and `{ signedOut: true }` both start without a session, and differ in what a later login resolves to: `null` forgets the identity, `signedOut` keeps it. A declared `null` is a declaration in its own right, so it overrides the default signed-in user — only an absent declaration means the test said nothing. + +`setAccount` writes to `mock.account` on the configuration rather than to a field on the builder, so the user travels the ordinary builder pipeline: a callback is resolved by `_buildConfig` with the same arguments every other configuration callback receives, and the result is on the raw configuration before validation. The schema strips the key, so nothing about a test reaches `MsalProvider` — the branch exists purely to carry the declaration across the builder. `MsalMockConfig` is exported for code that reads it. + +`setAccount` records configuration only — the user is signed in on the client as it is built, so it may be declared at any point before initialization and the last declaration wins. Keeping the user off `MsalClientConfig` is what lets `MsalMockClient` take the same argument the real `MsalClient` takes: a client is configured with *what it talks to*, never with *who is signed in*. + +Because the user is in place before `MsalProvider.initialize()` runs, the provider's own start-up path acts on it. Pairing `{ signedOut: true }` with `setRequiresAuth(true)` therefore exercises the real automatic login, rather than a state assigned after initialization had already finished. + +Because who is signed in is session state rather than client configuration, the user is applied to whichever client the module ends up authenticating through — wherever that client was built. When the module is hoisted onto a host application's provider, no client is built here, so the user is signed in on the *host's* client instead. Without that, a declaration made in an application's test would silently do nothing precisely when the application is being tested inside a portal. The session is shared, so the host sees the same user, as it does in production; when the host does not authenticate through a mock client the declaration throws rather than failing quietly. + +`setClient` replaces the client, but not the rule: a mock client supplied that way receives the declared user too. diff --git a/.changeset/service-discovery-mock-entry-point.md b/.changeset/service-discovery-mock-entry-point.md new file mode 100644 index 0000000000..1fe03e0350 --- /dev/null +++ b/.changeset/service-discovery-mock-entry-point.md @@ -0,0 +1,26 @@ +--- +"@equinor/fusion-framework-module-service-discovery": minor +--- + +Added a `./mock` entry point exporting `mockServiceDiscovery`, `enableServiceDiscoveryMock`, `ServiceDiscoveryMockConfigurator` and `ServiceDiscoveryMockClient`, so service discovery resolves from an in-memory registry instead of the network. + +```ts +import { mockServiceDiscovery } from '@equinor/fusion-framework-module-service-discovery/mock'; + +mockServiceDiscovery(configurator, { services: [{ key: 'apps', uri: 'https://apps.test' }] }); +``` + +`ServiceDiscoveryMockConfigurator` builds the registry on the builder itself — `setBaseUri`, `addService`, `addServices`, `removeService`, `setServices`, `setResolveUnknownServices` — and the client is constructed from that registry when the module builds its config. A test never has to construct a client just to add a service or point services at a local mock server. + +```ts +import { enableServiceDiscoveryMock } from '@equinor/fusion-framework-module-service-discovery/mock'; + +enableServiceDiscoveryMock(configurator, (builder) => { + builder.setBaseUri('http://localhost:6669'); + builder.addService({ key: 'my-api' }); +}); +``` + +`setBaseUri` lets the default service endpoints point at a local mock server such as `http://localhost:3000`, letting an application make real HTTP calls against Mockoon, Prism or the dev server without a service worker intercepting requests. + +Also widened `configureServiceDiscovery` to accept a synchronous callback. The underlying builder always allowed it; only the exported type required a promise. diff --git a/.changeset/service-discovery-provider-client.md b/.changeset/service-discovery-provider-client.md new file mode 100644 index 0000000000..6631319835 --- /dev/null +++ b/.changeset/service-discovery-provider-client.md @@ -0,0 +1,11 @@ +--- +"@equinor/fusion-framework-module-service-discovery": minor +--- + +Expose the discovery client on the provider as `client`, mirroring `MsalProvider.client`. + +Resolution already went through `config.discoveryClient`, but reaching it required knowing the config shape. A stable accessor gives tests a target their own test runner can spy on, and gives application code a way to inspect the client without depending on the configuration layout. + +```ts +vi.spyOn(fusion.modules.serviceDiscovery.client, 'resolveService').mockResolvedValue(service); +``` diff --git a/packages/framework/README.md b/packages/framework/README.md index b6d0fcf7e6..b28f653ad3 100644 --- a/packages/framework/README.md +++ b/packages/framework/README.md @@ -94,6 +94,35 @@ The framework ships with the following modules enabled by default: - **`@equinor/fusion-framework-module-context`** — application / project context selection - **`@equinor/fusion-framework-module-telemetry`** — telemetry, logging, and metadata +## Testing + +Import from `@equinor/fusion-framework/mock` to initialize the framework in a test without credentials, without network access and without configuration. + +```typescript +import { mockFramework } from '@equinor/fusion-framework/mock'; + +const fusion = await mockFramework((configurator) => { + configurator.msal.setAccount({ name: 'Ada Lovelace' }); + configurator.serviceDiscovery.setBaseUri('http://localhost:6669'); +}); + +const token = await fusion.modules.auth.acquireAccessToken(); +const apps = await fusion.modules.serviceDiscovery.resolveService('apps'); +``` + +The **real** configure → initialize pipeline runs with the **real** built-in modules; only the boundaries that leave the process — the MSAL client and the service discovery client — are substituted. Module wiring, configuration validation and lifecycle hooks therefore behave as they do in production, so a test still catches wiring mistakes. + +The entry point has **no test-runner dependency**, and ships no mocking API of its own — spying on an individual call is your test runner's job. + +## Documentation + +| Guide | Covers | +| --- | --- | +| [Testing](./docs/testing.md) | Choosing the user, signed-out tests, composing the service registry, spying, and configuring as an application does | +| [Testing — design](./docs/testing-design.md) | What is substituted and why, where mocks live, determinism, and test-runner support | +| [Testing — adding a mock for another module](./docs/testing-extending.md) | Giving your own module a test double, and which framework modules are not covered yet | +| [Testing — API](./docs/testing-api.md) | Every mock export and the package that owns it | + ## Further reading 📚 [Full documentation](https://equinor.github.io/fusion-framework/) diff --git a/packages/framework/docs/testing-api.md b/packages/framework/docs/testing-api.md new file mode 100644 index 0000000000..cc3d2b8deb --- /dev/null +++ b/packages/framework/docs/testing-api.md @@ -0,0 +1,20 @@ +# API + +| Export | Owner | Purpose | +| --- | --- | --- | +| `mockFramework(configure?)` | `/mock` | Build and initialize a framework instance for a test | +| `FrameworkMockConfigurator` | `/mock` | `FrameworkConfigurator` whose outward boundaries are mocked, exposing `msal` and `serviceDiscovery` | +| `enableMsalMock(configurator, configure?)` | `-module-msal/mock` | Register the auth module with an in-process MSAL client | +| `msalMockModule` | `-module-msal/mock` | The auth module with a mock client, for manual registration | +| `MsalMockConfigurator` | `-module-msal/mock` | `MsalConfigurator` backed by a mock client (`setAccount`, `setClient`, …) | +| `MsalMockClient(config)` | `-module-msal/mock` | Build the in-process MSAL client on its own, from the same `MsalClientConfig` as `MsalClient` | +| `createMsalMockClient(config, user?)` | `-module-msal/mock` | Convenience alias for `new MsalMockClient(config)`, optionally signing a user in | +| `createMockToken(claims?)` | `-module-msal/mock` | Mint a deterministic JWT | +| `mockServiceDiscovery(configurator, options?, configure?)` | `-module-service-discovery/mock` | Replace service discovery with an in-memory registry | +| `enableServiceDiscoveryMock(configurator, configure?)` | `-module-service-discovery/mock` | Register the discovery module with an in-memory registry | +| `ServiceDiscoveryMockConfigurator` | `-module-service-discovery/mock` | `ServiceDiscoveryConfigurator` that builds an in-memory registry (`setBaseUri`, `addService`, …) | +| `ServiceDiscoveryMockClient(options?)` | `-module-service-discovery/mock` | Build the in-memory discovery client on its own | +| `defaultServiceDiscoveryMockServices` | `-module-service-discovery/mock` | Baseline services a Fusion app resolves at start-up | +| `createMockService(service, baseUri?)` | `-module-service-discovery/mock` | Expand a sparse service declaration into a full `Service` | + +Module-owned exports are re-exported from `@equinor/fusion-framework/mock` for convenience; importing them from their own package is equally valid. diff --git a/packages/framework/docs/testing-design.md b/packages/framework/docs/testing-design.md new file mode 100644 index 0000000000..ecab52a3b9 --- /dev/null +++ b/packages/framework/docs/testing-design.md @@ -0,0 +1,63 @@ +# Design + +Why this entry point exists, what it substitutes, and the rules that keep it from growing into a mocking library. + +## Why this exists + +Fusion Framework cannot start without authenticating a user and resolving services from a registry. Both reach outside the process, so an application test either has to supply real credentials or hand-build a replacement for every built-in module. + +This entry point removes that work. It runs the **real** configure → initialize pipeline with the **real** built-in modules, and substitutes only the boundaries that leave the process. + +That distinction matters: module wiring, configuration validation and lifecycle hooks behave exactly as they do in production, so a test still catches wiring mistakes. + +## How mocks are organised + +> [!IMPORTANT] +> This entry point contains **no mock logic**. Every module owns and exports its own test double from a `./mock` entry point. This entry point only composes the built-in set. + +``` +@equinor/fusion-framework-module-msal/mock -> enableMsalMock +@equinor/fusion-framework-module-service-discovery/mock -> mockServiceDiscovery +@equinor/fusion-framework/mock -> composes the above +``` + +Three consequences follow, and they are the reason for the split: + +- **Mocks cannot drift.** A test double lives beside the implementation it stands in for, so a change to the real interface breaks it in the same package, in the same build. +- **Mocks version with their module.** Installing msal `v10` gets msal `v10`'s mock. There is no version matrix to reconcile. +- **Application modules work the same way.** A team's own module exposes its own mock entry point and composes without this entry point knowing it exists. + +Adding mock support to another Fusion module means adding a `src/mock/` folder to that module — not editing this entry point. + +## What is actually substituted + +> [!IMPORTANT] +> Only the **client** — the object that performs network I/O — is replaced. Providers, configurators, schema validation and module `initialize` are all real. + +For authentication this means `MsalProvider` itself runs. A test therefore observes real provider behaviour, including decisions the provider makes on the caller's behalf: + +```typescript +const fusion = await mockFramework((configurator) => { + configurator.msal.setClientConfig({ auth: { clientId: 'my-app', tenantId: 'my-tenant' } }); +}); + +const token = await fusion.modules.auth.acquireAccessToken(); +// scope is 'my-app/.default' — resolved by the real provider, not by the test double +``` + +A double that replaced the provider would have skipped that logic and quietly reported whatever it was told to. + +## Determinism + +Tokens and resolved services are identical across runs and across machines. `createMockToken` uses a fixed issue time, so a token can be compared or snapshotted directly. + +## Test-runner support + +The package has **no test-runner dependency**. It builds a real framework instance and returns it; assertions are the caller's concern. It works under Vitest today and would work unchanged under another runner. + +`vitest` appears only in `devDependencies`, for this entry point's own tests. + +> [!IMPORTANT] +> This is deliberate, and it is why there is **no Fusion mocking API**. The framework's job is making the runtime *substitutable* — a real configurator that validates, an I/O-boundary client that needs no network or credentials, a registry you compose on the builder. Replacing an individual call is your runner's job, and it does it better: call assertions, argument matchers and reset semantics you already know. +> +> Mock clients are therefore plain classes with ordinary methods. `vi.spyOn`, `bun:test`'s `spyOn` and Node's `t.mock.method` all work on them directly. diff --git a/packages/framework/docs/testing-extending.md b/packages/framework/docs/testing-extending.md new file mode 100644 index 0000000000..9b30c2ff62 --- /dev/null +++ b/packages/framework/docs/testing-extending.md @@ -0,0 +1,86 @@ +# Adding a mock for another module + +How to give a module a test double, whether it is one of ours or one of yours. + +## Mocking your own module + +An application module needs no support from this entry point. Ship a test double next to the module and apply it alongside the framework mocks. + +Two seams matter, and the second is the one teams miss: + +1. **A client seam** — `setClient`, the way `MsalConfigurator.setClient` and `setServiceDiscoveryClient` do — so the object performing I/O can be swapped. +2. **Configuration on the builder** — so a test can adjust behaviour *without* constructing a client at all. `ServiceDiscoveryMockConfigurator.addService` is the reference: the configurator accumulates config, and the client is built from it when the module assembles its config. + +```typescript +// @my-app/module-invoices/mock +export const mockInvoices = (configurator, options = {}) => { + configurator.addConfig( + configureInvoices((builder) => { + builder.setClient({ + getInvoice: async (id) => ({ id, total: options.total ?? 100 }), + }); + }), + ); +}; +``` + +```typescript +// @my-app/module-invoices — the module's own type, exported for tests +export type InvoiceModule = Module<'invoices', InvoiceClient, InvoiceConfigurator>; +``` + +Pass that descriptor to `mockFramework` as a type argument, and the module is typed on both the configurator and the returned instance: + +```typescript +const fusion = await mockFramework<[InvoiceModule]>((configurator) => { + enableInvoicesMock(configurator, { total: 42 }); +}); + +await fusion.modules.invoices.getInvoice('inv-1'); // typed, no cast +``` + +### Giving your module the same accessor as `.msal` and `.serviceDiscovery` + +`enableInvoicesMock(configurator, options)` is enough on its own — the configurator it builds is discarded once configuration runs, which is fine when a test only ever sets options up front. Reach for an accessor when a test needs the configurator itself, for example to assert against it after the fact. + +Subclass `FrameworkMockConfigurator` and use the protected `_pin`/`_getConfig` pair it exposes for exactly this — the same mechanism `.msal` and `.serviceDiscovery` are built from: + +```typescript +class AppMockConfigurator extends FrameworkMockConfigurator<[InvoiceModule]> { + constructor() { + super(); + this._pin(invoiceMockModule); + } + + get invoices(): InvoiceMockConfigurator { + return this._getConfig('invoices'); + } +} +``` + +`_pin` replaces the module's own `configure` factory with one that always returns the same instance — pinning it before initialization runs is what lets a test reach `.invoices` synchronously and have it be the configurator the module is actually built from. `_getConfig` looks that instance up by name, throwing if nothing was pinned for it. + +## What is not covered yet + +`.services`, `.context` and `.telemetry` are already reachable on `FrameworkMockConfigurator` — their `configure` factories take no `ref`, so they were safe to pin the same way `.msal` and `.serviceDiscovery` are. What is missing is a test double behind them: none of the three modules has a `src/mock/` folder yet, so anything issuing an actual request through them still reaches the network. Adding one means creating that folder in **that module**, then pinning its mock configurator with `_pin` and exposing it with `_getConfig` on `FrameworkMockConfigurator`, replacing the real module descriptor pinned there today. + +`event` is not pinned at all, deliberately: its `configure` factory reads `ref` to wire event bubbling to a parent event provider when `FrameworkMockConfigurator` is hoisted inside a host framework. Pinning would call `configure()` with `ref` always `undefined`, silently breaking that bubbling — so it is left to build the normal way, from the module system's own configure phase, where `ref` is actually known. + +## `.http` + +`.http` is backed by `HttpMockConfigurator` (`@equinor/fusion-framework-module-http/mock`): every named client it builds answers requests from registered route handlers instead of the network, so a test needs no locally running server. + +```typescript +configurator.http.configureClient('catalog', { baseUri: 'https://api.example.com' }); +configurator.http.get('/items', () => Response.json([{ id: 1 }])); + +const items = await fusion.modules.http.createClient('catalog').json('/items'); +``` + +Route handlers are Fetch-standard middleware — `(request: Request) => Response | undefined | Promise<...>` — matched in registration order, with `undefined` falling through to the next one. Three ways to fill that seam: + +- **`.get`/`.post`/`.put`/`.patch`/`.delete`/`.on`** — hand-rolled handlers for a handful of routes. +- **`fromExpressStyleHandler`** — adapts an Express-style `(req, res)` handler (or a whole framework built from them, like `openapi-backend`) into middleware, so `.use(fromExpressStyleHandler(api.handleRequest))` drops it straight in. +- **`fromOpenApiMock`** — adapts an `@equinor/fusion-openapi-mock` instance (`createOpenApiMock(document)`), so a real `openapi.json`/`openapi.yaml` fakes every response with no handlers written at all until an edge case needs overriding. + +All three are exported from `@equinor/fusion-framework-module-http/mock`. diff --git a/packages/framework/docs/testing.md b/packages/framework/docs/testing.md new file mode 100644 index 0000000000..74a8d91a9b --- /dev/null +++ b/packages/framework/docs/testing.md @@ -0,0 +1,161 @@ +# Usage + +Recipes for the situations a test normally runs into. Every one of them builds a real framework instance — only the boundaries that leave the process are substituted. + +`mockFramework` takes a single callback, which receives a [`FrameworkMockConfigurator`](#the-configurator). That configurator **is** a `FrameworkConfigurator`, so everything an application does at configure time works here unchanged. + +## Zero configuration + +```typescript +import { mockFramework } from '@equinor/fusion-framework/mock'; + +const fusion = await mockFramework(); + +fusion.modules.auth.account?.name; // 'Test User' +await fusion.modules.serviceDiscovery.resolveService('apps'); // resolves offline +``` + +Every module declared by `FrameworkConfigurator` is initialized: `event`, `auth`, `http`, `serviceDiscovery`, `context` and `telemetry`. + +## The configurator + +Modules whose boundary is mocked expose their mock configurator directly, so a test configures them without registering a callback: + +| Property | Type | +| --- | --- | +| `msal` | `MsalMockConfigurator` | +| `serviceDiscovery` | `ServiceDiscoveryMockConfigurator` | + +```typescript +const fusion = await mockFramework((configurator) => { + configurator.msal.setAccount({ name: 'Ada Lovelace' }); + configurator.serviceDiscovery.setBaseUri('http://localhost:6669'); +}); +``` + +These are the real module configurators, so the real builder API, the real validation and the real provider are used. + +## Choosing the signed-in user + +```typescript +const fusion = await mockFramework((configurator) => { + configurator.msal.setAccount({ name: 'Ada Lovelace', username: 'ada@equinor.com' }); +}); + +const token = await fusion.modules.auth.acquireAccessToken({ + request: { scopes: ['Files.Read'] }, +}); +``` + +The token is a structurally valid JWT carrying the claims an application reads. It is unsigned by default and must never be accepted by anything but a test. + +`setAccount` records configuration only — the user is signed in on the client before the provider initializes. It takes an object, `null` when nobody is signed in, or an ordinary config-builder callback resolving either: + +```typescript +configurator.msal.setAccount(null); +configurator.msal.setAccount(async ({ hasModule }) => ({ + name: hasModule('app') ? 'App User' : 'Portal User', +})); +``` + +Because the user is in place before `MsalProvider.initialize()` runs, the provider's real start-up path acts on it — pair `signedOut` with `setRequiresAuth(true)` to watch the automatic login happen. + +The account replaces any previously declared account, and is signed in on whichever client the module authenticates through — the one it builds, one supplied through `setClient`, or the host's when the module is hoisted onto a host application's provider. When that client cannot represent a declared user, it throws rather than failing quietly. + +## Testing signed-out behaviour + +```typescript +const fusion = await mockFramework((configurator) => { + configurator.msal.setAccount({ signedOut: true }); +}); + +fusion.modules.auth.account; // null +``` + +Silent flows then resolve empty so the provider follows its unauthenticated path, while an explicit `login()` still succeeds — so a test can drive the sign-in journey, not only its end state. + +## Composing the service registry + +Nothing has to be constructed to add a service, or to move every service to a locally running mock server such as Mockoon or Prism — in which case the application makes real HTTP calls with nothing intercepting them. + +```typescript +const fusion = await mockFramework((configurator) => { + configurator.serviceDiscovery.setBaseUri('http://localhost:6669'); + configurator.serviceDiscovery.addService({ key: 'my-api' }); + configurator.serviceDiscovery.removeService('bookmarks'); +}); +``` + +To replace the baseline registry outright rather than compose onto it, use `setServices`: + +```typescript +configurator.serviceDiscovery.setServices([{ key: 'apps', uri: 'http://localhost:3000' }]); +``` + +By default an undeclared service resolves to a synthesised entry rather than throwing, so a test does not fail merely because the application resolved something the test did not think to declare. Call `setResolveUnknownServices(false)` to assert the opposite. + +> [!WARNING] +> Built-in modules resolve services **while the framework starts** — the context module resolves `context`, for example. Combining `setResolveUnknownServices(false)` with a `setServices` registry that omits them fails initialization rather than the assertion you were writing. Either keep synthesis on, or declare every service the framework itself needs. + +## Mocking an individual call + +That is your test runner's job, not this entry point's. Mock clients are plain classes with ordinary methods, so any runner can spy on them with its own tooling — including call assertions and its own reset semantics. + +```typescript +vi.spyOn(fusion.modules.serviceDiscovery.client, 'resolveService').mockResolvedValue(service); + +afterEach(() => vi.restoreAllMocks()); +``` + +The same holds for `bun:test`'s `spyOn` and Node's `t.mock.method`, which is why this entry point introduces no mocking API of its own. + +## Configuring the framework as an application does + +The configurator is a `FrameworkConfigurator`, so every `enableX` and `configureX` helper is available and behaves normally. + +```typescript +const fusion = await mockFramework((configurator) => { + configurator.onConfigured(() => { + /* ... */ + }); +}); +``` + +Mocks are registered **before** the callback runs, so anything configured there wins — including replacing a mock with a different one. + +## Registering your own modules + +Pass your module descriptors as a type argument. They are then typed on both the configurator and the returned instance, so no cast is needed to reach them. + +```typescript +const fusion = await mockFramework<[InvoiceModule]>((configurator) => { + enableInvoicesMock(configurator, { total: 42 }); +}); + +await fusion.modules.invoices.getInvoice('inv-1'); // fully typed +``` + +`addModule` is available if it reads better at the call site; it is sugar for the same call. + +```typescript +configurator.addModule((c) => enableInvoicesMock(c, { total: 42 })); +``` + +> [!NOTE] +> Only modules that ship a mock configurator get a property such as `configurator.msal`. Everything else is registered exactly as it is in production — through its own `enableX` helper or `configurator.addConfig`. Module *instances* never exist at configure time; they are created by `initialize`. + +See [Adding a mock for another module](./testing-extending.md) for how to give your module a test double, and a property on the configurator. + +## Bringing your own configurator + +`FrameworkMockConfigurator` can be constructed directly and initialized with `init`, which is useful when a test needs to hold on to the configurator. + +```typescript +import { init } from '@equinor/fusion-framework'; +import { FrameworkMockConfigurator } from '@equinor/fusion-framework/mock'; + +const configurator = new FrameworkMockConfigurator(); +configurator.msal.setAccount({ name: 'Ada Lovelace' }); + +const fusion = await init(configurator); +``` diff --git a/packages/framework/package.json b/packages/framework/package.json index 444583a225..211fdadfb5 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -8,6 +8,10 @@ ".": { "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" } }, "scripts": { @@ -18,7 +22,9 @@ "keywords": [ "fusion", "fusion-framework", - "utility" + "utility", + "testing", + "mock" ], "homepage": "https://equinor.github.io/fusion-framework/", "author": { diff --git a/packages/framework/src/__tests__/mock/application-module.test.ts b/packages/framework/src/__tests__/mock/application-module.test.ts new file mode 100644 index 0000000000..2c1630a766 --- /dev/null +++ b/packages/framework/src/__tests__/mock/application-module.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import type { IModulesConfigurator, Module } from '@equinor/fusion-framework-module'; + +import { mockFramework } from '../../mock/index.js'; + +/** + * A module an application team owns, which the framework knows nothing about. + * + * @remarks + * The point of these tests is that nothing below required support from + * `@equinor/fusion-framework/mock`. If an application team can do this, the + * extension pattern holds. + */ +interface InvoiceClient { + getInvoice(id: string): Promise<{ id: string; total: number }>; +} + +class InvoiceConfigurator { + #client?: InvoiceClient; + + public setClient(client: InvoiceClient): void { + this.#client = client; + } + + public createClient(): InvoiceClient { + if (!this.#client) { + throw new Error('An invoice client is required'); + } + return this.#client; + } +} + +type InvoiceModule = Module<'invoices', InvoiceClient, InvoiceConfigurator>; + +const invoiceModule: InvoiceModule = { + name: 'invoices', + configure: () => new InvoiceConfigurator(), + initialize: ({ config }) => config.createClient(), +}; + +/** The team's own mock, following the pattern the built-in modules use. */ +const enableInvoicesMock = ( + // biome-ignore lint/suspicious/noExplicitAny: mirrors every enableX helper + configurator: IModulesConfigurator, + options: { total?: number } = {}, +): void => { + configurator.addConfig({ + module: invoiceModule, + configure: (builder: InvoiceConfigurator) => + builder.setClient({ + getInvoice: async (id) => ({ id, total: options.total ?? 0 }), + }), + } as { module: InvoiceModule }); +}; + +describe('application modules', () => { + it('fails to start without its mock, so the seam is real and not a no-op', async () => { + await expect( + mockFramework<[InvoiceModule]>((configurator) => { + configurator.addConfig({ module: invoiceModule } as { module: InvoiceModule }); + }), + ).rejects.toThrow(/invoice client is required/i); + }); + + it('composes with the built-in mocks and is typed without a cast', async () => { + const fusion = await mockFramework<[InvoiceModule]>((configurator) => { + configurator.msal.setAccount({ name: 'Ada Lovelace' }); + enableInvoicesMock(configurator, { total: 42 }); + }); + + // No cast: `TModules` must flow through to `fusion.modules`. + const invoice = await fusion.modules.invoices.getInvoice('inv-1'); + + expect(invoice).toEqual({ id: 'inv-1', total: 42 }); + expect(fusion.modules.auth.account?.name).toBe('Ada Lovelace'); + }); + + it('is registered exactly as it is in production', async () => { + // The same helper, against a real FrameworkConfigurator, would behave identically. + const fusion = await mockFramework<[InvoiceModule]>((configurator) => + enableInvoicesMock(configurator, { total: 7 }), + ); + + await expect(fusion.modules.invoices.getInvoice('inv-2')).resolves.toMatchObject({ total: 7 }); + }); +}); diff --git a/packages/framework/src/__tests__/mock/documented-usage.test.ts b/packages/framework/src/__tests__/mock/documented-usage.test.ts new file mode 100644 index 0000000000..03a0758156 --- /dev/null +++ b/packages/framework/src/__tests__/mock/documented-usage.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { decodeJwtSegment } from '@equinor/fusion-framework-module-msal/mock'; + +import { init } from '../../init.js'; +import { createMockService, FrameworkMockConfigurator, mockFramework } from '../../mock/index.js'; + +/** + * Pins the snippets in `docs/testing.md` and the package README to real + * behaviour, so documentation cannot drift away from the API it describes. + */ +describe('documented usage', () => { + afterEach(() => vi.restoreAllMocks()); + + it('composes the service registry on the builder', async () => { + const fusion = await mockFramework((configurator) => { + configurator.serviceDiscovery.setBaseUri('http://localhost:6669'); + configurator.serviceDiscovery.addService({ key: 'my-api' }); + configurator.serviceDiscovery.removeService('bookmarks'); + }); + + await expect(fusion.modules.serviceDiscovery.resolveService('my-api')).resolves.toMatchObject({ + key: 'my-api', + }); + }); + + it('replaces the baseline registry with setServices', async () => { + const fusion = await mockFramework((configurator) => { + configurator.serviceDiscovery.setServices([{ key: 'apps', uri: 'http://localhost:3000' }]); + }); + + await expect(fusion.modules.serviceDiscovery.resolveService('apps')).resolves.toMatchObject({ + uri: 'http://localhost:3000', + }); + }); + + it('keeps startup working when the registry is replaced, because unknown services synthesise', async () => { + // The context module resolves `context` while initializing, so a replaced + // registry that omits it would break start-up if synthesis were disabled. + const fusion = await mockFramework((configurator) => { + configurator.serviceDiscovery.setServices([{ key: 'apps', uri: 'http://localhost:3000' }]); + }); + + await expect(fusion.modules.serviceDiscovery.resolveService('people')).resolves.toBeDefined(); + }); + + it('lets the test runner spy on the discovery client', async () => { + const fusion = await mockFramework(); + + vi.spyOn(fusion.modules.serviceDiscovery.client, 'resolveService').mockResolvedValue( + createMockService({ key: 'apps', uri: 'http://spied' }), + ); + + await expect(fusion.modules.serviceDiscovery.resolveService('apps')).resolves.toMatchObject({ + uri: 'http://spied', + }); + }); + + it('lets the test runner spy on the auth client', async () => { + const fusion = await mockFramework(); + + const spy = vi.spyOn(fusion.modules.auth.client, 'acquireToken'); + + await fusion.modules.auth.acquireAccessToken({ request: { scopes: ['Files.Read'] } }); + + expect(spy).toHaveBeenCalled(); + }); + + it('resolves the real scope through the real provider, not the test double', async () => { + const fusion = await mockFramework((configurator) => { + // The client is configured with what it talks to, exactly as in production + configurator.msal.setClientConfig({ auth: { clientId: 'my-app', tenantId: 'my-tenant' } }); + }); + + const token = await fusion.modules.auth.acquireAccessToken(); + const claims = JSON.parse(decodeJwtSegment(token?.split('.')[1] ?? '')); + + // MsalProvider — not the mock — turns "no scopes requested" into `${clientId}/.default` + expect(claims.scp).toBe('my-app/.default'); + }); + + it('signs nobody in when the account is signed out', async () => { + const fusion = await mockFramework((configurator) => { + configurator.msal.setAccount({ signedOut: true }); + }); + + expect(fusion.modules.auth.account).toBeFalsy(); + }); + + it('can be constructed directly and initialized with init', async () => { + const configurator = new FrameworkMockConfigurator(); + configurator.msal.setAccount({ name: 'Ada Lovelace' }); + + const fusion = await init(configurator); + + expect(fusion.modules.auth.account?.name).toBe('Ada Lovelace'); + }); +}); diff --git a/packages/framework/src/__tests__/mock/mock-framework.test.ts b/packages/framework/src/__tests__/mock/mock-framework.test.ts new file mode 100644 index 0000000000..f943eb0a44 --- /dev/null +++ b/packages/framework/src/__tests__/mock/mock-framework.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; + +import type { Module } from '@equinor/fusion-framework-module'; +import { enableMsalMock } from '@equinor/fusion-framework-module-msal/mock'; +import { enableServiceDiscoveryMock } from '@equinor/fusion-framework-module-service-discovery/mock'; + +import { FrameworkConfigurator } from '../../FrameworkConfigurator.js'; +import { init } from '../../init.js'; +import { FrameworkMockConfigurator, mockFramework } from '../../mock/index.js'; + +/** A minimal configurator, standing in for an application's own. */ +class WidgetsConfigurator { + #name = 'default'; + setName(name: string): this { + this.#name = name; + return this; + } + get name(): string { + return this.#name; + } +} + +/** A minimal module, standing in for one an application supplies through `TModules`. */ +type WidgetsModule = Module<'widgets', { name: string }, WidgetsConfigurator>; +const widgetsModule: WidgetsModule = { + name: 'widgets', + configure: () => new WidgetsConfigurator(), + initialize: ({ config }) => ({ name: config.name }), +}; + +describe('mockFramework', () => { + it('initializes every built-in module without configuration', async () => { + const fusion = await mockFramework(); + + expect(fusion.modules.event).toBeDefined(); + expect(fusion.modules.auth).toBeDefined(); + expect(fusion.modules.http).toBeDefined(); + expect(fusion.modules.serviceDiscovery).toBeDefined(); + expect(fusion.modules.context).toBeDefined(); + expect(fusion.modules.telemetry).toBeDefined(); + }); + + it('signs in a default user so an application has an identity to read', async () => { + const fusion = await mockFramework(); + + expect(fusion.modules.auth.account?.name).toBe('Test User'); + }); + + it('resolves the services a Fusion application needs at start-up', async () => { + const fusion = await mockFramework(); + + await expect(fusion.modules.serviceDiscovery.resolveService('apps')).resolves.toMatchObject({ + key: 'apps', + }); + }); + + it('passes a real FrameworkConfigurator to the callback', async () => { + expect.assertions(2); + + await mockFramework((configurator) => { + expect(configurator).toBeInstanceOf(FrameworkMockConfigurator); + expect(configurator).toBeInstanceOf(FrameworkConfigurator); + }); + }); + + it('awaits an asynchronous callback before initializing', async () => { + const fusion = await mockFramework(async (configurator) => { + await Promise.resolve(); + configurator.msal.setAccount({ name: 'Ada Lovelace' }); + }); + + expect(fusion.modules.auth.account?.name).toBe('Ada Lovelace'); + }); +}); + +describe('FrameworkMockConfigurator', () => { + it('exposes the same msal configurator the auth module is built from', async () => { + const fusion = await mockFramework((configurator) => { + configurator.msal.setAccount({ name: 'Ada Lovelace', username: 'ada@equinor.com' }); + }); + + expect(fusion.modules.auth.account).toMatchObject({ + name: 'Ada Lovelace', + username: 'ada@equinor.com', + }); + }); + + it('lets the last declared account win', async () => { + const fusion = await mockFramework((configurator) => { + configurator.msal.setAccount({ name: 'Ada Lovelace' }); + configurator.msal.setAccount({ name: 'Grace Hopper' }); + }); + + expect(fusion.modules.auth.account?.name).toBe('Grace Hopper'); + }); + + it('resolves an account callback when the config is built', async () => { + const fusion = await mockFramework((configurator) => { + configurator.msal.setAccount(async () => ({ name: 'Ada Lovelace' })); + }); + + expect(fusion.modules.auth.account?.name).toBe('Ada Lovelace'); + }); + + it('builds the auth client when the module builds its config, not when the account is set', async () => { + const configurator = new FrameworkMockConfigurator(); + + configurator.msal.setAccount({ name: 'Ada Lovelace' }); + + // The account is configuration; nothing is constructed from it yet + expect(configurator.msal.getClient()).toBeUndefined(); + + const fusion = await init(configurator); + + expect(fusion.modules.auth.account?.name).toBe('Ada Lovelace'); + }); + + it('exposes the same service discovery configurator the module is built from', async () => { + const fusion = await mockFramework((configurator) => { + configurator.serviceDiscovery.setBaseUri('http://localhost:6669'); + configurator.serviceDiscovery.addService({ key: 'my-api' }); + }); + + const service = await fusion.modules.serviceDiscovery.resolveService('my-api'); + + expect(service.uri).toContain('http://localhost:6669'); + }); + + it('lets a service be removed so its absence can be asserted', async () => { + const fusion = await mockFramework((configurator) => { + configurator.serviceDiscovery.setResolveUnknownServices(false); + configurator.serviceDiscovery.removeService('bookmarks'); + }); + + await expect(fusion.modules.serviceDiscovery.resolveService('bookmarks')).rejects.toThrow(); + }); + + it('accepts an enableX helper directly, because it is a real configurator', async () => { + const fusion = await mockFramework((configurator) => { + enableMsalMock(configurator, (builder) => builder.setAccount({ name: 'Grace Hopper' })); + enableServiceDiscoveryMock(configurator, (builder) => builder.addService({ key: 'my-api' })); + }); + + expect(fusion.modules.auth.account?.name).toBe('Grace Hopper'); + await expect(fusion.modules.serviceDiscovery.resolveService('my-api')).resolves.toBeDefined(); + }); + + it('registers a module through addModule', async () => { + const fusion = await mockFramework((configurator) => { + configurator.addModule((c) => + enableMsalMock(c, (builder) => builder.setAccount({ name: 'Grace Hopper' })), + ); + }); + + expect(fusion.modules.auth.account?.name).toBe('Grace Hopper'); + }); + + it('returns itself from addModule so calls can be chained', () => { + const configurator = new FrameworkMockConfigurator(); + + expect(configurator.addModule(() => undefined)).toBe(configurator); + }); + + it('exposes the same http configurator the http module is built from', async () => { + const configurator = new FrameworkMockConfigurator(); + + configurator.http.configureClient('my-api', { baseUri: 'http://localhost:6669' }); + + const fusion = await init(configurator); + + expect(fusion.modules.http.createClient('my-api')).toBeDefined(); + }); + + it('exposes the same services configurator the services module is built from', () => { + const configurator = new FrameworkMockConfigurator(); + + expect(configurator.services).toBeDefined(); + }); + + it('exposes the same context configurator the context module is built from', () => { + const configurator = new FrameworkMockConfigurator(); + + expect(configurator.context).toBeDefined(); + }); + + it('exposes the same telemetry configurator the telemetry module is built from', () => { + const configurator = new FrameworkMockConfigurator(); + + expect(configurator.telemetry).toBeDefined(); + }); + + it('lets a module supplied through TModules get the same kind of accessor as msal and serviceDiscovery', async () => { + // Standing in for an application subclassing FrameworkMockConfigurator to + // expose its own module the same way the built-ins are exposed. + class AppMockConfigurator extends FrameworkMockConfigurator<[WidgetsModule]> { + constructor() { + super(); + this._pin(widgetsModule); + } + get widgets(): WidgetsConfigurator { + return this._getConfig('widgets'); + } + } + + const configurator = new AppMockConfigurator(); + configurator.widgets.setName('Ada'); + + const fusion = await init(configurator); + + expect(fusion.modules.widgets.name).toBe('Ada'); + }); + + it('pins the same instance across repeated reads, so declarations accumulate on one configurator', () => { + class AppMockConfigurator extends FrameworkMockConfigurator<[WidgetsModule]> { + constructor() { + super(); + this._pin(widgetsModule); + } + get widgets(): WidgetsConfigurator { + return this._getConfig('widgets'); + } + } + + const configurator = new AppMockConfigurator(); + + expect(configurator.widgets).toBe(configurator.widgets); + }); + + it('throws from _getConfig when nothing was pinned for that name', () => { + class AppMockConfigurator extends FrameworkMockConfigurator { + readMissing(): unknown { + return this._getConfig('widgets'); + } + } + + expect(() => new AppMockConfigurator().readMissing()).toThrow(/widgets/); + }); + + it('throws from _pin when the module declares no configure factory', () => { + class AppMockConfigurator extends FrameworkMockConfigurator { + pinMissingConfigure(): void { + this._pin({ ...widgetsModule, configure: undefined } as WidgetsModule); + } + } + + expect(() => new AppMockConfigurator().pinMissingConfigure()).toThrow(/configure factory/); + }); +}); diff --git a/packages/framework/src/init.ts b/packages/framework/src/init.ts index 461a7b1ae6..30e7ee4827 100644 --- a/packages/framework/src/init.ts +++ b/packages/framework/src/init.ts @@ -45,8 +45,13 @@ export const init = async , TRef extends objec const fusion = { modules, }; - // Expose globally so portal shells and widgets can access the running instance - window.Fusion = fusion as unknown as Fusion; + // Expose globally so portal shells and widgets can access the running instance. + // Guarded because the framework must also initialize where no DOM exists, such as + // a test runner or a server-side render. + if (typeof window !== 'undefined') { + // Global exposure predates strict typing on `Window.Fusion`; the shape is guaranteed by this function's own construction above + window.Fusion = fusion as unknown as Fusion; + } modules.event.dispatchEvent('onFrameworkLoaded', { detail: fusion }); // The generic TModules type is erased on the plain object above; restore it for the return type diff --git a/packages/framework/src/mock/FrameworkMockConfigurator.ts b/packages/framework/src/mock/FrameworkMockConfigurator.ts new file mode 100644 index 0000000000..24e89e3b93 --- /dev/null +++ b/packages/framework/src/mock/FrameworkMockConfigurator.ts @@ -0,0 +1,263 @@ +import type { AnyModule } from '@equinor/fusion-framework-module'; + +import contextModule, { + type ContextModuleConfigurator, +} from '@equinor/fusion-framework-module-context'; +import { + httpMockModule, + type HttpMockConfigurator, +} from '@equinor/fusion-framework-module-http/mock'; +import { + msalMockModule, + type MsalMockConfigurator, +} from '@equinor/fusion-framework-module-msal/mock'; +import { + serviceDiscoveryMockModule, + type ServiceDiscoveryMockConfigurator, +} from '@equinor/fusion-framework-module-service-discovery/mock'; +import servicesModule, { type IApiConfigurator } from '@equinor/fusion-framework-module-services'; +import telemetryModule, { + type ITelemetryConfigurator, +} from '@equinor/fusion-framework-module-telemetry'; + +import { FrameworkConfigurator } from '../FrameworkConfigurator.js'; + +/** + * The real framework configurator, with every built-in module that reaches + * outside the process backed by a test double, and every other built-in + * module reachable the same way. + * + * @remarks + * Nothing else changes: the same module set, the same configuration pipeline and + * the same lifecycle are used. Only the boundaries that would need credentials or + * network access are substituted, so a test still exercises module wiring, + * configuration validation and lifecycle hooks. + * + * Every built-in module exposes its own configurator as a property, so a test + * reaches it directly instead of registering a callback to receive it. `http` + * answers registered route handlers instead of the network — see `.http` and + * `enableHttpMock`'s adapters for `openapi-backend`-style backends or an + * `@equinor/fusion-openapi-mock` document. `services`, `context` and + * `telemetry` are not backed by test doubles yet, so calls through their + * configurators still reach the network — but the configurators themselves + * are reachable the same way `.msal` is, since their `configure` factories + * take no `ref` and so lose nothing by being pinned early. + * + * `event` is deliberately not pinned: its `configure` factory reads `ref` to + * wire bubbling to a parent event provider when this configurator is hoisted + * inside a host framework, and pinning would freeze that decision before a + * `ref` could ever be known. + * + * Because this *is* a `FrameworkConfigurator`, every `enableX` helper an + * application already uses accepts it unchanged — including the ones an + * application team writes for their own modules. + * + * @typeParam TModules - Module descriptors beyond the built-in set. Supply this + * when a test registers application modules, so they are typed on the resulting + * instance. + * + * @example + * ```typescript + * const configurator = new FrameworkMockConfigurator(); + * + * configurator.msal.setAccount({ name: 'Ada Lovelace' }); + * configurator.serviceDiscovery.setBaseUri('http://localhost:6669'); + * + * const fusion = await init(configurator); + * ``` + */ +export class FrameworkMockConfigurator< + TModules extends Array = [], +> extends FrameworkConfigurator { + static override readonly className: string = 'FrameworkMockConfigurator'; + + // Keyed by module name, so `_getConfig` can look a pinned configurator up + // without needing the module descriptor again. + #configurators = new Map(); + + /** + * Creates a framework configurator backed by the built-in mock modules. + */ + constructor() { + super(); + + // Pinning up front — rather than waiting for an accessor to be read — is + // what replaces the modules `FrameworkConfigurator`'s own constructor + // already registered, whether or not a test ever touches the accessor. + this._pin(msalMockModule); + this._pin(serviceDiscoveryMockModule); + this._pin(httpMockModule); + this._pin(servicesModule); + this._pin(contextModule); + this._pin(telemetryModule); + } + + /** + * Pins a module to a single configurator instance for the lifetime of this + * configurator, so it can be reached by name through {@link _getConfig}. + * + * @remarks + * The module system otherwise builds a fresh configurator from its own + * `configure` factory during the configure phase — too late for a test to + * reach, and a new instance on every call besides. This replaces that + * factory with one that always returns the same instance, and registers the + * result under the module's own name. + * + * A subclass registering a module supplied through {@link TModules} uses + * this the same way `.msal` and `.serviceDiscovery` do, to expose its own + * named accessor: + * + * ```typescript + * class MyMockConfigurator extends FrameworkMockConfigurator<[InvoiceModule]> { + * constructor() { + * super(); + * this._pin(invoiceMockModule); + * } + * + * public get invoices(): InvoiceMockConfigurator { + * return this._getConfig('invoices'); + * } + * } + * ``` + * + * @param module - The module descriptor to pin a configurator for. + * @template TModule - The specific module descriptor type being pinned. + * @throws {Error} If the module declares no `configure` factory to pin, or + * the factory returns a promise instead of a configurator — pinning is + * synchronous, so a test can reach the accessor immediately. + */ + protected _pin(module: TModule): void { + // A module without a configure factory has nothing this method could pin + if (!module.configure) { + throw new Error(`Cannot pin "${module.name}": it declares no configure factory.`); + } + const instance = module.configure(); + // Async factories would make the pinned instance unavailable until the module system + // resolves it later, defeating the point of pinning it for immediate synchronous access + if (instance instanceof Promise) { + throw new Error( + `Cannot pin "${module.name}": its configure factory returns a promise, so it cannot be resolved synchronously.`, + ); + } + this.#configurators.set(module.name, instance); + this.addConfig({ module: { ...module, configure: () => instance } as TModule }); + } + + /** + * Returns the configurator pinned for a module by name. + * + * @param name - The module's name, as passed to {@link _pin}. + * @template TConfig - The specific configurator type expected for this module. + * @returns The configurator pinned under `name`. + * @throws {Error} If no configurator has been pinned for that name. + */ + protected _getConfig(name: string): TConfig { + const config = this.#configurators.get(name); + // A missing entry means _pin was never called for this module name + if (config === undefined) { + throw new Error( + `No configurator is pinned for module "${name}" — call this._pin(module) before this._getConfig("${name}").`, + ); + } + return config as TConfig; + } + + /** + * Configures the user the framework signs in. + * + * @remarks + * The same {@link MsalMockConfigurator} the auth module is configured from, so + * a change made here is what the module sees. + * + * @returns The MSAL mock configurator. + */ + public get msal(): MsalMockConfigurator { + return this._getConfig(msalMockModule.name); + } + + /** + * Configures the registry services are resolved from. + * + * @remarks + * The same {@link ServiceDiscoveryMockConfigurator} the service discovery + * module is configured from, so a change made here is what the module sees. + * + * @returns The service discovery mock configurator. + */ + public get serviceDiscovery(): ServiceDiscoveryMockConfigurator { + return this._getConfig(serviceDiscoveryMockModule.name); + } + + /** + * Configures the HTTP module's named clients. + * + * @remarks + * The same {@link HttpMockConfigurator} the HTTP module is configured from, + * so every client it builds answers requests from registered route handlers + * instead of the network — no locally running server needed. + * + * @returns The HTTP mock configurator. + */ + public get http(): HttpMockConfigurator { + return this._getConfig(httpMockModule.name); + } + + /** + * Configures the typed API clients the `services` module builds. + * + * @remarks + * The real configurator — `services` has no test double yet. + * + * @returns The real API configurator. + */ + public get services(): IApiConfigurator { + return this._getConfig(servicesModule.name); + } + + /** + * Configures context resolution. + * + * @remarks + * The real configurator — `context` has no test double yet. + * + * @returns The real context module configurator. + */ + public get context(): ContextModuleConfigurator { + return this._getConfig(contextModule.name); + } + + /** + * Configures telemetry. + * + * @remarks + * The real configurator — `telemetry` has no test double yet. + * + * @returns The real telemetry configurator. + */ + public get telemetry(): ITelemetryConfigurator { + return this._getConfig(telemetryModule.name); + } + + /** + * Registers a module through its own enabler. + * + * @remarks + * Sugar for calling the enabler directly — `enableMyModuleMock(configurator)` + * works just as well, because this class *is* a `FrameworkConfigurator`. Use + * whichever reads better at the call site. + * + * @param configure - Callback receiving this configurator. + * @returns This configurator, for chaining. + * + * @example + * ```typescript + * configurator.addModule((c) => enableMyModuleMock(c, { total: 42 })); + * ``` + */ + public addModule(configure: (configurator: this) => void): this { + configure(this); + return this; + } +} + +export default FrameworkMockConfigurator; diff --git a/packages/framework/src/mock/index.ts b/packages/framework/src/mock/index.ts new file mode 100644 index 0000000000..3ac13e1fc1 --- /dev/null +++ b/packages/framework/src/mock/index.ts @@ -0,0 +1,49 @@ +/** + * Zero-configuration Fusion Framework instances for tests. + * + * @remarks + * Lets an application initialize the real framework — real modules, real + * configuration pipeline, real lifecycle — while the boundaries that reach + * outside the process are substituted with deterministic fakes. No credentials, + * no network access and no configuration are required. + * + * This entry point holds **no mock logic**. Each module owns and exports its own + * test double from its `./mock` entry point; this entry point only composes the + * built-in set into a ready-to-use instance. An application module follows the + * same pattern and plugs in identically. + * + * This entry point is test-runner agnostic: it contains no dependency on Vitest + * or any other test framework, so the same helpers work under any runner. + * + * @packageDocumentation + */ + +export { mockFramework, type FrameworkMockConfigureFn } from './mock-framework.js'; +export { FrameworkMockConfigurator } from './FrameworkMockConfigurator.js'; + +// Re-exported so a test can reach the built-in module mocks without importing +// each module's `./mock` entry point directly. The mocks are owned by their modules. +export { + enableMsalMock, + msalMockModule, + MsalMockConfigurator, + MsalMockClient, + createMsalMockClient, + createMockToken, + type AuthConfigMockFn, + type MsalMockUser, + type MockTokenClaims, +} from '@equinor/fusion-framework-module-msal/mock'; + +export { + mockServiceDiscovery, + enableServiceDiscoveryMock, + serviceDiscoveryMockModule, + ServiceDiscoveryMockClient, + ServiceDiscoveryMockConfigurator, + createMockService, + defaultServiceDiscoveryMockServices, + type ServiceDiscoveryConfigMockFn, + type MockService, + type ServiceDiscoveryMockClientOptions, +} from '@equinor/fusion-framework-module-service-discovery/mock'; diff --git a/packages/framework/src/mock/mock-framework.ts b/packages/framework/src/mock/mock-framework.ts new file mode 100644 index 0000000000..c81d3f1517 --- /dev/null +++ b/packages/framework/src/mock/mock-framework.ts @@ -0,0 +1,72 @@ +import type { AnyModule } from '@equinor/fusion-framework-module'; + +import { init } from '../init.js'; +import type { Fusion } from '../types.js'; + +import { FrameworkMockConfigurator } from './FrameworkMockConfigurator.js'; + +/** + * Configures a mocked framework before it is initialized. + * + * @typeParam TModules - Module descriptors beyond the built-in set. + * @param configurator - The configurator to configure. + */ +export type FrameworkMockConfigureFn = []> = ( + configurator: FrameworkMockConfigurator, +) => void | Promise; + +/** + * Starts a Fusion framework instance that needs no credentials and no network. + * + * @remarks + * The real framework is started: the real module set, the real configuration + * pipeline and the real lifecycle. Only the boundaries that would need + * credentials or network access are substituted, so a test exercises the wiring + * an application actually depends on rather than a reimplementation of it. + * + * The configurator passed to `configure` is a {@link FrameworkMockConfigurator}, + * which *is* a `FrameworkConfigurator`. Every `enableX` helper therefore accepts + * it unchanged — including the ones an application team writes for their own + * modules. + * + * Spying on individual calls is left to the test runner. The framework makes the + * runtime substitutable; `vi.spyOn`, `bun:test` `spyOn` and `t.mock.method` all + * work against the resulting instance with no framework support. + * + * @typeParam TModules - Module descriptors beyond the built-in set. Supply this + * when a test registers application modules, so they are typed on the result. + * @template TModules - Module descriptors beyond the built-in set. + * @param configure - Callback that configures the framework before it starts. + * @returns The initialized framework instance. + * + * @example Zero configuration + * ```typescript + * const fusion = await mockFramework(); + * ``` + * + * @example Configure the built-in mocks + * ```typescript + * const fusion = await mockFramework((configurator) => { + * configurator.msal.setAccount({ name: 'Ada Lovelace' }); + * configurator.serviceDiscovery.setBaseUri('http://localhost:6669'); + * }); + * ``` + * + * @example Register an application module + * ```typescript + * const fusion = await mockFramework<[InvoiceModule]>((configurator) => { + * enableInvoicesMock(configurator, { total: 42 }); + * }); + * + * await fusion.modules.invoices.getInvoice('1'); + * ``` + */ +export async function mockFramework = []>( + configure?: FrameworkMockConfigureFn, +): Promise> { + const configurator = new FrameworkMockConfigurator(); + await configure?.(configurator); + return await init(configurator); +} + +export default mockFramework; diff --git a/packages/modules/http/README.md b/packages/modules/http/README.md index d6380c91fe..1533c8d67d 100644 --- a/packages/modules/http/README.md +++ b/packages/modules/http/README.md @@ -193,12 +193,19 @@ See [Server-Sent Events](docs/server-sent-events.md) for `sse$()` usage, event f Native fetch errors can still surface as well, including abort and network failures. +## Testing + +Import from `@equinor/fusion-framework-module-http/mock` to answer requests from registered route handlers instead of the network, with adapters for `openapi-backend`-style handlers and `@equinor/fusion-openapi-mock`. + +See [Testing](docs/testing.md) for the middleware contract, `enableHttpMock`, and both adapters. + ## Advanced Guides - [Client Configuration](docs/client-configuration.md): named clients, `configureHttpClient`, `configureHttp`, `onCreate`, custom client classes, and ad-hoc clients - [Observable Patterns](docs/observable-patterns.md): `fetch$`, `json$`, `request$`, `response$`, cancellation, and RxJS composition - [Selectors and Handlers](docs/selectors-and-handlers.md): `jsonSelector`, `blobSelector`, request handlers, response handlers, and built-in operators - [Server-Sent Events](docs/server-sent-events.md): `sse$`, `createSseSelector`, `sseMap`, event filtering, heartbeats, and abort behavior +- [Testing](docs/testing.md): `enableHttpMock`, the middleware contract, and the `fromExpressStyleHandler`/`fromOpenApiMock` adapters ## Things To Remember diff --git a/packages/modules/http/docs/testing.md b/packages/modules/http/docs/testing.md new file mode 100644 index 0000000000..9927152c92 --- /dev/null +++ b/packages/modules/http/docs/testing.md @@ -0,0 +1,200 @@ +# Testing + +Import from `@equinor/fusion-framework-module-http/mock` to answer requests from registered route handlers instead of the network. The real configurator API — `configureClient`, `baseUri`, `defaultScopes`, `requestHandler`, `onCreate` — all still applies; only the network call itself is replaced. + +## Quick Start + +```typescript +import { enableHttpMock } from '@equinor/fusion-framework-module-http/mock'; + +enableHttpMock(configurator, (builder) => { + builder.configureClient('catalog', { baseUri: 'https://api.example.com' }); + builder.get('/items', () => Response.json([{ id: 1 }])); +}); + +const items = await fusion.modules.http.createClient('catalog').json('/items'); +``` + +`enableHttpMock` replaces whichever HTTP module the configurator already carries — including a real one a `FrameworkConfigurator` pre-registers — so it must be called last, after any other HTTP setup. + +## The Middleware Contract + +A route handler is Fetch-standard middleware: + +```typescript +type HttpMockMiddleware = (request: Request) => Response | undefined | Promise; +``` + +- **Matching is tried in registration order.** The first handler to return a `Response` (instead of `undefined`) wins; register more specific routes before more general ones (e.g. before a catch-all `.use(fromOpenApiMock(...))`). +- **Returning (or resolving to) `undefined` declines the request**, falling through to the next registered handler — this is how `.get`/`.post`/`.use` compose with each other and with a whole adapted backend. +- **One router is shared by every named client the configurator builds**, matched against each request's fully resolved URL (`baseUri` + path). Two clients with the same path on different `baseUri`s never collide. +- **The request is cloned before each handler runs**, so one handler reading the body (`request.json()`, `request.text()`) does not exhaust it for the next handler in the chain. +- **`.on(method, match, handler)`** — `method` is matched case-insensitively, or pass `undefined` to match any method. `match` is either a substring tested against the full resolved URL (`url.includes(match)`) or a `RegExp` tested against it (`match.test(url)`) — there is no path-template syntax (`/pets/:id`); use a `RegExp` when you need to extract or ignore parts of the path. +- **`.get`/`.post`/`.put`/`.patch`/`.delete`** are sugar for `.on('GET'|'POST'|..., match, handler)`. +- **`.use(handler)`** registers a handler for every method and every URL — this is the extension point an adapted backend (or a hand-rolled catch-all) registers through. + +## What Happens When No Handler Matches + +Nothing falls back to the real network, and nothing synthesizes a `404 Response`. Exhausting every registered handler throws instead: + +``` +Error: No mock handler matched GET https://api.example.com/items. Register one with configurator.http.use(...), .on(...), or .get/.post/.put/.patch/.delete(...). +``` + +This is deliberate: a mocked client can never reach a live backend by accident, and a forgotten route registration fails the test immediately and legibly — pointing at the exact method and URL — instead of masquerading as "just another 404" your code might otherwise swallow. If you want a route to *answer* with a 404 (as opposed to leaving it unregistered), register it explicitly and return a `Response` with that status — see below. + +## Mocking Statuses, Headers, and Error Bodies + +There is no separate "mock a status" or "mock a header" API — you build the same `Response` (or `res`) you would in the real handler. What that looks like depends on which of the three ways you're filling the seam. + +### Directly with `.get`/`.post`/`.use` + +Construct the `Response` however you like; every static and instance member of the Fetch `Response` is available: + +```typescript +// custom status + headers +builder.get( + '/items', + () => new Response(JSON.stringify([{ id: 1 }]), { + status: 201, + headers: { 'content-type': 'application/json', 'x-total-count': '1' }, + }), +); + +// shorthand for a JSON body — sets `content-type: application/json` for you +builder.get('/items', () => Response.json([{ id: 1 }], { status: 201, headers: { 'x-total-count': '1' } })); + +// an error status is just a `Response` with that status — no throwing needed +builder.get('/items/999', () => Response.json({ error: 'not found' }, { status: 404 })); + +// an empty response (matches `new Response(null, ...)` semantics — no content-type is set for a null body) +builder.delete('/items/1', () => new Response(null, { status: 204 })); + +// simulate a network-level failure instead of an HTTP error response — +// reject/throw from the handler and it propagates like any other thrown error +builder.get('/items', () => { + throw new Error('simulated network failure'); +}); + +// async handlers work the same way — return a `Promise` +builder.get('/items', async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return Response.json([{ id: 1 }]); +}); +``` + +> **`new Response(string)` sets a default `content-type`.** Passing a plain string body (as opposed to `null`/`undefined`) sets `content-type: text/plain;charset=UTF-8` unless you override it in `headers` — this is native `Response` behavior, not something the mock adds. + +### Through `fromExpressStyleHandler` (`openapi-backend` and other Express-style handlers) + +The handler gets a real `res`-shaped object ([`MockExpressResponse`](../src/mock/adapters/MockExpressResponse.ts)) with `.status()`/`.setHeader()` accumulating until a terminal call (`.json`/`.send`/`.end`) builds the final `Response`: + +```typescript +api.register('getUserById', (c, req, res) => { + res.status(404).setHeader('x-reason', 'not-found').json({ error: 'not found' }); +}); + +api.register('createUser', (c, req, res) => { + res.status(201).setHeader('location', `/users/${newId}`).json({ id: newId }); +}); +``` + +- `.status(code)` — sets the status; defaults to `200` if never called. +- `.setHeader(name, value)` — sets one header; call it multiple times for multiple headers. Chainable, and order relative to `.status()` doesn't matter — both only take effect once a terminal method fires. +- `.json(body)` — stringifies `body` and sets `content-type: application/json`. +- `.send(body?)` — a string body passes through as-is; any other value is JSON-stringified (with `content-type: application/json` set); calling it with no body sends an empty response with whatever status/headers were set. +- `.end(body?)` — same as `.send`, without the JSON-serialization special case for non-string bodies (a non-string, non-undefined body is coerced with `String(body)`). +- Only the first terminal call takes effect — a real response can only be sent once, and later calls are silently ignored. + +### Through `fromOpenApiMock` + +Status comes from the OpenAPI document itself: `createOpenApiMock` picks the lowest documented `2xx` status for the matched operation (falling back to its `default` response, or `200` if neither exists), and fakes the body from that response's schema — so the status is whatever the spec declares, not always `200`. There is **no header support** through this adapter — every response is built with `Response.json(mock, { status })`, so only `content-type: application/json` is ever set. + +To control the status for one operation, register an override — its return value's `status` field replaces the declared one: + +```typescript +const openApiMock = createOpenApiMock(openApiDocument, { + overrides: { + getPetById: async (ctx) => { + if (ctx.params.petId === '999') { + return { status: 404, mock: { message: 'Pet not found' } }; + } + return ctx.mockResponseForOperation(); + }, + }, +}); +``` + +To control **headers** for one operation, don't rely on `fromOpenApiMock` for that route at all — register a direct `.get`/`.on` handler for it *before* `.use(fromOpenApiMock(...))` in the chain (registration order decides which one answers first): + +```typescript +builder.get('/pets/999', () => Response.json({ message: 'Pet not found' }, { + status: 404, + headers: { 'x-error-code': 'PET_NOT_FOUND' }, +})); +builder.use(fromOpenApiMock(openApiMock)); +``` + +## Filling The Seam + +Three ways to fill it, from least to most turn-key: + +- **`.get`/`.post`/`.put`/`.patch`/`.delete`/`.on`/`.use`** — register a handler or arbitrary middleware directly. +- **`fromExpressStyleHandler`** — adapts an Express-style `(req, res)` handler, so a whole framework built from them (`openapi-backend`, for example) drops in with `.use(fromExpressStyleHandler(api.handleRequest))`. +- **`fromOpenApiMock`** — adapts an [`@equinor/fusion-openapi-mock`](../../utils/openapi-mock) instance, so a real `openapi.json`/`openapi.yaml` fakes every response — with a seed for repeatable output — until a specific operation needs overriding: + + ```typescript + import { createOpenApiMock } from '@equinor/fusion-openapi-mock'; + import { fromOpenApiMock } from '@equinor/fusion-framework-module-http/mock'; + + const openApiMock = createOpenApiMock(openApiDocument, { seed: 42 }); + enableHttpMock(configurator, (builder) => { + builder.configureClient('catalog', { baseUri: 'https://api.example.com' }); + builder.use(fromOpenApiMock(openApiMock)); + }); + ``` + +Neither adapter takes a dependency on the library it adapts — construct the real instance and pass it in. All three compose freely in one router; a request tries every registered handler, in registration order, regardless of which of these ways registered it. + +## Resetting Between Tests + +Call `resetHandlers()` on the configurator (or `reset()` directly on a router) to clear every registered handler, so one test's routes never leak into the next: + +```typescript +afterEach(() => { + configurator.http.resetHandlers(); +}); +``` + +## Asserting Calls With `vi.fn` + +A handler is a plain function, so a `vi.fn` spy works as one directly — no extra wiring: + +```typescript +const handler = vi.fn(() => Response.json({ ok: true })); +builder.get('/items', handler); + +await client.json('/items'); + +expect(handler).toHaveBeenCalledOnce(); +const [request] = handler.mock.calls[0]; +expect(request.method).toBe('GET'); +``` + +## Multiple Named Clients + +Registering handlers is independent of which named client(s) end up calling them — the shared router only matches on the request's fully resolved URL, not on which client made the call. Configure as many named clients as you need; give each its own `baseUri` so their paths don't collide even when they reuse the same route strings: + +```typescript +enableHttpMock(configurator, (builder) => { + builder.configureClient('catalog', { baseUri: 'https://api.example.com' }); + builder.configureClient('billing', { baseUri: 'https://billing.example.com' }); + + builder.get('https://api.example.com/items', () => Response.json([{ id: 1 }])); + builder.get('https://billing.example.com/items', () => Response.json([{ id: 'inv-1' }])); +}); +``` + +A `match` that is just a path (e.g. `'/items'`) matches by substring against the *full* resolved URL, so it still matches both clients above unless you include enough of the host to disambiguate, or use a `RegExp` anchored to one host. + +See [`@equinor/fusion-framework/mock`](../../framework/docs/testing-extending.md) to mock every framework boundary at once. diff --git a/packages/modules/http/package.json b/packages/modules/http/package.json index c95ba8d0ea..0b6c5e3641 100644 --- a/packages/modules/http/package.json +++ b/packages/modules/http/package.json @@ -13,6 +13,10 @@ "import": "./dist/esm/lib/client/index.js", "types": "./dist/types/lib/client/index.d.ts" }, + "./mock": { + "import": "./dist/esm/mock/index.js", + "types": "./dist/types/mock/index.d.ts" + }, "./operators": { "import": "./dist/esm/lib/operators/index.js", "types": "./dist/types/lib/operators/index.d.ts" @@ -34,6 +38,9 @@ "client": [ "dist/types/lib/client/index.d.ts" ], + "mock": [ + "dist/types/mock/index.d.ts" + ], "operators": [ "dist/types/lib/operators/index.d.ts" ], diff --git a/packages/modules/http/src/lib/client/client.ts b/packages/modules/http/src/lib/client/client.ts index 0cb8578ff4..3a1c5aa4b6 100644 --- a/packages/modules/http/src/lib/client/client.ts +++ b/packages/modules/http/src/lib/client/client.ts @@ -368,7 +368,7 @@ export class HttpClient< /** push request to event buss */ tap((x) => this._request$.next(x)), /** execute request */ - switchMap(({ uri, path: _path, ...init }) => fromFetch(uri, init)), + switchMap(({ uri, path: _path, ...init }) => this._performFetch(uri, init)), /** prepare response, allow extensions to modify response */ switchMap((x) => this._prepareResponse(x as unknown as TResponse)), /** push response to event buss */ @@ -400,6 +400,24 @@ export class HttpClient< return response$ as unknown as Observable; } + /** + * Performs the actual network call for a prepared request. + * + * @remarks + * Isolated from {@link _fetch$} so a test double can replace only this step — + * matching a request against registered route handlers instead of reaching + * the network — while everything around it (request preparation, the + * response pipeline, abort handling) runs unchanged. See + * `@equinor/fusion-framework-module-http/mock`. + * + * @param uri - The fully resolved URL for the request. + * @param init - The prepared `fetch` request options. + * @returns An observable of the raw `Response`, ahead of {@link _prepareResponse}. + */ + protected _performFetch(uri: string, init: RequestInit): ObservableInput { + return fromFetch(uri, init); + } + /** * Prepares the request by passing it through the `requestHandler.process()` method. * This method is an implementation detail of the `_fetch$()` method, and is not part of the public API. diff --git a/packages/modules/http/src/mock/HttpMockConfigurator.ts b/packages/modules/http/src/mock/HttpMockConfigurator.ts new file mode 100644 index 0000000000..50108d1d31 --- /dev/null +++ b/packages/modules/http/src/mock/HttpMockConfigurator.ts @@ -0,0 +1,156 @@ +import { HttpClientConfigurator } from '../configurator'; +import type { HttpClientMsal } from '../lib/client'; + +import { createHttpClientMockCtor } from './create-http-client-mock-ctor'; +import { HttpMockRouter, type HttpMockMiddleware } from './HttpMockRouter'; + +/** + * The real HTTP configurator, with every client it builds answering requests + * from registered route handlers instead of the network. + * + * @remarks + * Nothing else changes: named clients are still registered with + * {@link HttpClientConfigurator.configureClient | configureClient}, `baseUri`, + * `defaultScopes`, `requestHandler` and `onCreate` all still apply, and the + * client returned is still an `HttpClientMsal` — only the network call itself + * is replaced. + * + * One router is shared by every client this configurator builds, matched + * against each request's fully resolved URL — so two named clients with + * different `baseUri`s don't collide even when they use the same path. + * + * A whole backend — `openapi-backend`, a hand-rolled router, anything shaped + * `(request: Request) => Response | undefined` — drops in through `.use`, + * exactly like middleware; see + * `@equinor/fusion-framework-module-http/mock` for + * `fromExpressStyleHandler`, the adapter for backends with an Express-style + * `(req, res)` handler shape instead. + * + * @example + * ```typescript + * configurator.http.configureClient('catalog', { baseUri: 'https://api.example.com' }); + * configurator.http.get('/items', () => Response.json([{ id: 1 }])); + * + * const items = await fusion.modules.http.createClient('catalog').json('/items'); + * ``` + */ +export class HttpMockConfigurator extends HttpClientConfigurator { + #router: HttpMockRouter; + + /** Creates a configurator with an empty shared mock router. */ + constructor() { + const router = new HttpMockRouter(); + super(createHttpClientMockCtor(router)); + this.#router = router; + } + + /** + * Registers a middleware, run for every request regardless of method or URL. + * + * @remarks + * Delegates to the shared {@link HttpMockRouter}. This is the extension + * point for dropping in a whole backend as one registration; see + * {@link HttpMockRouter.use}. + * + * @returns This configurator, for chaining. + * @param handler - Answers each request, or returns `undefined` to continue. + */ + public use(handler: HttpMockMiddleware): this { + this.#router.use(handler); + return this; + } + + /** + * Registers a handler for a method and URL match. + * + * @remarks + * Delegates to the shared {@link HttpMockRouter}. See + * {@link HttpMockRouter.on} for matching rules. + * + * @returns This configurator, for chaining. + * @param method - The request method to match, or `undefined` for any method. + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + */ + public on(method: string | undefined, match: string | RegExp, handler: HttpMockMiddleware): this { + this.#router.on(method, match, handler); + return this; + } + + /** + * Registers a handler for `GET` requests. + * + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + * @returns This configurator, for chaining. + * @see {@link on} + */ + public get(match: string | RegExp, handler: HttpMockMiddleware): this { + this.#router.get(match, handler); + return this; + } + + /** + * Registers a handler for `POST` requests. + * + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + * @returns This configurator, for chaining. + * @see {@link on} + */ + public post(match: string | RegExp, handler: HttpMockMiddleware): this { + this.#router.post(match, handler); + return this; + } + + /** + * Registers a handler for `PUT` requests. + * + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + * @returns This configurator, for chaining. + * @see {@link on} + */ + public put(match: string | RegExp, handler: HttpMockMiddleware): this { + this.#router.put(match, handler); + return this; + } + + /** + * Registers a handler for `PATCH` requests. + * + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + * @returns This configurator, for chaining. + * @see {@link on} + */ + public patch(match: string | RegExp, handler: HttpMockMiddleware): this { + this.#router.patch(match, handler); + return this; + } + + /** + * Registers a handler for `DELETE` requests. + * + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + * @returns This configurator, for chaining. + * @see {@link on} + */ + public delete(match: string | RegExp, handler: HttpMockMiddleware): this { + this.#router.delete(match, handler); + return this; + } + + /** + * Removes every registered handler. + * + * @returns This configurator, for chaining. + */ + public resetHandlers(): this { + this.#router.reset(); + return this; + } +} + +export default HttpMockConfigurator; diff --git a/packages/modules/http/src/mock/HttpMockRouter.ts b/packages/modules/http/src/mock/HttpMockRouter.ts new file mode 100644 index 0000000000..eb26cb6e6a --- /dev/null +++ b/packages/modules/http/src/mock/HttpMockRouter.ts @@ -0,0 +1,195 @@ +/** + * A middleware answers a request, or declines it by returning `undefined` (or + * a promise of `undefined`) so the next registered middleware — or the + * router's own "no handler matched" error — gets a chance instead. + * + * @remarks + * The signature is exactly `(request: Request) => Response | undefined`, on + * purpose: it is the same shape most fetch-based routers already use (Hono, + * itty-router, service-worker route handlers, `@whatwg-node/router`), so + * dropping one of those in as a middleware needs no adapter at all. A backend + * with a different shape — Express-style `(req, res)` handlers, or + * `openapi-backend`'s `handleRequest(request, ...args)` — needs a thin + * adapter; see {@link fromExpressStyleHandler}. + */ +export type HttpMockMiddleware = ( + request: Request, +) => Response | undefined | Promise; + +interface RegisteredMiddleware { + method?: string; + match?: string | RegExp; + handler: HttpMockMiddleware; +} + +/** + * Tests `url` against a match constraint. + * + * @remarks + * A `g`/`y` `RegExp` is stateful — `.test()` advances its `lastIndex`, so the + * same registration would match on one call and silently miss on the next. + * Resetting `lastIndex` first keeps a registered pattern reusable across requests. + */ +function matchesUrl(match: string | RegExp, url: string): boolean { + // A plain substring constraint has no statefulness to reset. + if (typeof match === 'string') return url.includes(match); + match.lastIndex = 0; + return match.test(url); +} + +/** + * Runs a chain of middleware against requests and answers them without + * reaching the network. + * + * @remarks + * Middleware runs in registration order; the first one to return a + * `Response` (rather than `undefined`) wins, exactly like a conventional + * middleware chain. `.get`/`.post`/`.put`/`.patch`/`.delete`/`.on` are sugar + * for the common "match a method and a URL" case — each registers a + * `use`-compatible middleware under the hood, so mixing them with a + * general-purpose backend registered through `.use` composes without + * surprises. + * + * This is the piece {@link HttpMockConfigurator} builds its client from — a + * test rarely constructs this class directly. + * + * @example Register a whole backend as one middleware + * ```typescript + * configurator.http.use(fromExpressStyleHandler((req, res) => api.handleRequest(req, req, res))); + * ``` + */ +export class HttpMockRouter { + #middleware: RegisteredMiddleware[] = []; + + /** + * Registers a middleware, run for every request regardless of method or URL. + * + * @remarks + * This is the extension point for dropping in a whole router or backend — + * `openapi-backend`, a hand-rolled `switch` on `request.method`/`request.url`, + * or any function shaped `(request: Request) => Response | undefined`. + * + * @param handler - Answers the request, or returns `undefined` to decline it. + * @returns This router, for chaining. + */ + public use(handler: HttpMockMiddleware): this { + this.#middleware.push({ handler }); + return this; + } + + /** + * Registers a middleware for a method and URL match. + * + * @param method - The request method to match, or `undefined` to match any method. + * @param match - A substring of, or a pattern tested against, the request's resolved URL. + * @param handler - Answers the request, or returns `undefined` to decline it. + * @returns This router, for chaining. + */ + public on(method: string | undefined, match: string | RegExp, handler: HttpMockMiddleware): this { + this.#middleware.push({ method: method?.toUpperCase(), match, handler }); + return this; + } + + /** + * Registers a middleware for `GET` requests. + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + * @returns This router, for chaining. + * @see {@link on} + */ + public get(match: string | RegExp, handler: HttpMockMiddleware): this { + return this.on('GET', match, handler); + } + + /** + * Registers a middleware for `POST` requests. + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + * @returns This router, for chaining. + * @see {@link on} + */ + public post(match: string | RegExp, handler: HttpMockMiddleware): this { + return this.on('POST', match, handler); + } + + /** + * Registers a middleware for `PUT` requests. + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + * @returns This router, for chaining. + * @see {@link on} + */ + public put(match: string | RegExp, handler: HttpMockMiddleware): this { + return this.on('PUT', match, handler); + } + + /** + * Registers a middleware for `PATCH` requests. + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + * @returns This router, for chaining. + * @see {@link on} + */ + public patch(match: string | RegExp, handler: HttpMockMiddleware): this { + return this.on('PATCH', match, handler); + } + + /** + * Registers a middleware for `DELETE` requests. + * @param match - The URL substring or pattern to match. + * @param handler - Answers the request, or returns `undefined` to continue. + * @returns This router, for chaining. + * @see {@link on} + */ + public delete(match: string | RegExp, handler: HttpMockMiddleware): this { + return this.on('DELETE', match, handler); + } + + /** + * Removes every registered middleware. + * + * @remarks + * Useful between tests that share a router, so one test's routes cannot + * leak into the next. + * + * @returns This router, for chaining. + */ + public reset(): this { + this.#middleware = []; + return this; + } + + /** + * Resolves a request against the registered middleware chain. + * + * @param url - The fully resolved URL of the request. + * @param init - The `fetch` request options the client prepared. + * @returns The first middleware's response. + * @throws {Error} If no middleware answers, naming the method and URL so the + * test failure points straight at the missing registration. + */ + public async resolve(url: string, init: RequestInit): Promise { + const request = new Request(url, init); + const method = request.method.toUpperCase(); + // Preserve registration order so the first matching middleware owns the response. + for (const registered of this.#middleware) { + // Skip handlers registered for another HTTP method. + if (registered.method && registered.method !== method) continue; + // Only apply URL matching when the registration specifies a URL constraint. + if (registered.match !== undefined && !matchesUrl(registered.match, url)) { + // Continue searching when this handler's URL constraint does not match. + continue; + } + // clone the request so one middleware reading the body (e.g. `.json()`) does not + // exhaust it for the next middleware in the chain + const response = await registered.handler(request.clone()); + // Stop at the first middleware that answers so later handlers cannot override it. + if (response !== undefined) return response; + } + throw new Error( + `No mock handler matched ${method} ${url}. Register one with configurator.http.use(...), .on(...), or .get/.post/.put/.patch/.delete(...).`, + ); + } +} + +export default HttpMockRouter; diff --git a/packages/modules/http/src/mock/adapters/MockExpressResponse.ts b/packages/modules/http/src/mock/adapters/MockExpressResponse.ts new file mode 100644 index 0000000000..95132bac35 --- /dev/null +++ b/packages/modules/http/src/mock/adapters/MockExpressResponse.ts @@ -0,0 +1,116 @@ +/** + * The subset of an Express-style response object {@link MockExpressResponse} + * implements, and every handler passed to `fromExpressStyleHandler` can call. + * + * @remarks + * Deliberately loose — `openapi-backend`'s handlers, Express's own, and most + * Connect-style middleware call only these, chained the same way + * `res.status(200).json(body)` is everywhere. + */ +export interface ExpressStyleResponse { + status(code: number): this; + setHeader(name: string, value: string): this; + json(body: unknown): this; + send(body?: unknown): this; + end(body?: unknown): this; +} + +/** + * A minimal, spec-compliant stand-in for an Express `res` object, capturing + * one of `.json`/`.send`/`.end` as a Fetch API `Response`. + * + * @remarks + * This is what lets `fromExpressStyleHandler` hand an Express-style handler + * something it already knows how to answer — `res.status(200).json(body)` — + * while the middleware chain around it only ever sees `Response`. + * + * Resolves {@link done} the first time a terminal method is called; later + * calls are ignored, matching how a real response can only be sent once. + */ +export class MockExpressResponse implements ExpressStyleResponse { + #status = 200; + #headers = new Headers(); + #resolve!: (response: Response) => void; + #settled = false; + + /** Resolves with the `Response` built from whichever terminal method was called. */ + public readonly done: Promise; + + /** Creates a response whose promise settles when a terminal method is called. */ + constructor() { + this.done = new Promise((resolve) => { + this.#resolve = resolve; + }); + } + + /** + * Sets the response status code. + * @param code - The HTTP status code. + * @returns This response, for chaining. + */ + public status(code: number): this { + this.#status = code; + return this; + } + + /** + * Sets a response header. + * @param name - The header name. + * @param value - The header value. + * @returns This response, for chaining. + */ + public setHeader(name: string, value: string): this { + this.#headers.set(name, value); + return this; + } + + /** + * Sends a JSON body, setting `Content-Type` and resolving {@link done}. + * @param body - The value to serialize. + * @returns This response, for chaining. + */ + public json(body: unknown): this { + this.#headers.set('content-type', 'application/json'); + this.#settle(JSON.stringify(body)); + return this; + } + + /** + * Sends a body as-is, resolving {@link done}. + * @param body - The body to send. + * @returns This response, for chaining. + */ + public send(body?: unknown): this { + // Serialize non-string values so callers receive a valid JSON response body. + if (body !== undefined && typeof body !== 'string') { + this.#headers.set('content-type', 'application/json'); + this.#settle(JSON.stringify(body)); + } else { + this.#settle(body ?? null); + } + return this; + } + + /** + * Ends the response, optionally with a body, resolving {@link done}. + * @param body - The optional body to send. + * @returns This response, for chaining. + */ + public end(body?: unknown): this { + this.#settle(typeof body === 'string' || body === undefined ? (body ?? null) : String(body)); + return this; + } + + /** + * Resolves the response once, ignoring later terminal calls. + * @param body - The response body to include. + */ + #settle(body: BodyInit | null): void { + // A response can only be completed once, like a real Express response. + if (this.#settled) return; + this.#settled = true; + this.#resolve(new Response(body, { status: this.#status, headers: this.#headers })); + } +} + +export default MockExpressResponse; diff --git a/packages/modules/http/src/mock/adapters/from-express-style-handler.ts b/packages/modules/http/src/mock/adapters/from-express-style-handler.ts new file mode 100644 index 0000000000..1d279ebc11 --- /dev/null +++ b/packages/modules/http/src/mock/adapters/from-express-style-handler.ts @@ -0,0 +1,78 @@ +import type { HttpMockMiddleware } from '../HttpMockRouter'; + +import { MockExpressResponse, type ExpressStyleResponse } from './MockExpressResponse'; + +/** + * The default request shape {@link fromExpressStyleHandler} maps a `Request` + * to — the shape `openapi-backend` and most Express-style routers expect. + */ +export interface ExpressStyleRequest { + method: string; + path: string; + query: Record; + headers: Record; + body: unknown; +} + +async function defaultToExpressStyleRequest(request: Request): Promise { + const url = new URL(request.url); + const contentType = request.headers.get('content-type') ?? ''; + const text = request.body ? await request.clone().text() : ''; + const body = + contentType.includes('application/json') && text ? JSON.parse(text) : text || undefined; + return { + method: request.method, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: Object.fromEntries(request.headers.entries()), + body, + }; +} + +/** + * Adapts an Express-style `(req, res)` handler — including + * `openapi-backend`'s `handleRequest(request, ...args)`, which forwards + * whatever is passed after the routing request straight to the matched + * operation handler — into an {@link HttpMockMiddleware}. + * + * @remarks + * Only the request needs translating, from the Fetch API `Request` every + * middleware receives to whatever shape the handler expects; the response + * side is a {@link MockExpressResponse}, so `res.status(200).json(body)` — the + * call every Express-style handler already makes — becomes the middleware's + * `Response` with no further glue. + * + * @param handleRequest - Called with the mapped request and a {@link MockExpressResponse}. + * Anything it returns is ignored; the middleware waits for the response to + * answer through one of `.json`/`.send`/`.end` instead. + * @param toRequest - Builds the handler's expected request shape from the raw + * `Request`. Defaults to `{ method, path, query, headers, body }` — the + * shape `openapi-backend` and most Express-style routers expect. + * @returns A middleware for {@link HttpMockRouter.use}. + * @template TRequest - The request shape expected by the handler. + * + * @example Register a whole `openapi-backend` instance as one middleware + * ```typescript + * const api = new OpenAPIBackend({ definition: './petstore.yml' }); + * api.register({ getPets: (c, req, res) => res.status(200).json([{ id: 1 }]) }); + * await api.init(); + * + * configurator.http.use(fromExpressStyleHandler((req, res) => api.handleRequest(req, req, res))); + * ``` + */ +export function fromExpressStyleHandler( + handleRequest: (request: TRequest, response: ExpressStyleResponse) => unknown, + toRequest: (request: Request) => TRequest | Promise = defaultToExpressStyleRequest as ( + request: Request, + ) => TRequest | Promise, +): HttpMockMiddleware { + return async (request) => { + const req = await toRequest(request); + const res = new MockExpressResponse(); + // Await so a rejecting async handler surfaces here instead of as an unhandled rejection. + await handleRequest(req, res); + return res.done; + }; +} + +export default fromExpressStyleHandler; diff --git a/packages/modules/http/src/mock/adapters/from-open-api-mock.ts b/packages/modules/http/src/mock/adapters/from-open-api-mock.ts new file mode 100644 index 0000000000..983327e0ae --- /dev/null +++ b/packages/modules/http/src/mock/adapters/from-open-api-mock.ts @@ -0,0 +1,49 @@ +import type { HttpMockMiddleware } from '../HttpMockRouter'; + +/** + * The subset of `OpenApiMock` (from `@equinor/fusion-openapi-mock`'s + * `createOpenApiMock`) {@link fromOpenApiMock} needs — duck-typed so this + * package has no dependency on that one; anything shaped like this works. + */ +export interface OpenApiMockLike { + resolve(request: { + method: string; + path: string; + query?: Record; + }): Promise<{ status: number; mock: unknown } | undefined>; +} + +/** + * Adapts an `OpenApiMock` into an {@link HttpMockMiddleware}, so a whole + * OpenAPI document fakes every matching request with one registration. + * + * @remarks + * A request that matches no operation in the document resolves to + * `undefined`, which declines the middleware exactly like every other + * {@link HttpMockMiddleware} — so it composes with `.get`/`.post`/`.use` + * registrations covering endpoints outside the spec. + * + * @param openApiMock - Typically `createOpenApiMock(document)` from `@equinor/fusion-openapi-mock`. + * @returns A middleware for {@link HttpMockRouter.use}. + * + * @example Fake every operation in a spec, straight from the document + * ```typescript + * import { createOpenApiMock } from '@equinor/fusion-openapi-mock'; + * import openapi from './openapi.json' with { type: 'json' }; + * + * configurator.http.use(fromOpenApiMock(createOpenApiMock(openapi))); + * ``` + */ +export function fromOpenApiMock(openApiMock: OpenApiMockLike): HttpMockMiddleware { + return async (request) => { + const url = new URL(request.url); + const result = await openApiMock.resolve({ + method: request.method, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + }); + return result && Response.json(result.mock, { status: result.status }); + }; +} + +export default fromOpenApiMock; diff --git a/packages/modules/http/src/mock/adapters/index.ts b/packages/modules/http/src/mock/adapters/index.ts new file mode 100644 index 0000000000..eb5af7da4c --- /dev/null +++ b/packages/modules/http/src/mock/adapters/index.ts @@ -0,0 +1,6 @@ +export { + fromExpressStyleHandler, + type ExpressStyleRequest, +} from './from-express-style-handler'; +export { MockExpressResponse, type ExpressStyleResponse } from './MockExpressResponse'; +export { fromOpenApiMock, type OpenApiMockLike } from './from-open-api-mock'; diff --git a/packages/modules/http/src/mock/create-http-client-mock-ctor.ts b/packages/modules/http/src/mock/create-http-client-mock-ctor.ts new file mode 100644 index 0000000000..4110c728ea --- /dev/null +++ b/packages/modules/http/src/mock/create-http-client-mock-ctor.ts @@ -0,0 +1,44 @@ +import type { ObservableInput } from 'rxjs'; + +import { HttpClientMsal } from '../lib/client'; + +import type { HttpMockRouter } from './HttpMockRouter'; + +/** + * Builds an `HttpClientMsal` subclass bound to a single {@link HttpMockRouter}. + * + * @remarks + * `HttpClientConfigurator` takes a client constructor once, and constructs it + * itself (`new ctor(uri, options)`) — there is no room in that call to also + * pass a router. Binding the router through a closure over a class + * declaration is what lets a constructor built this way still resolve every + * request against it, without changing the constructor's public shape. + * + * Everything above the network call — `fetch`/`json`/`blob`/`sse$`, + * `requestHandler`, MSAL scope handling — is the real `HttpClientMsal` + * unchanged; only {@link HttpClientMsal._performFetch} is replaced, so a test + * exercises the same request preparation and response pipeline production + * traffic does. + * + * @param router - The router every client built from the returned constructor resolves requests against. + * @returns A client constructor for {@link HttpClientConfigurator}. + */ +export function createHttpClientMockCtor( + router: HttpMockRouter, +): new ( + uri: string, + options?: ConstructorParameters[1], +) => HttpClientMsal { + return class HttpClientMock extends HttpClientMsal { + /** + * Routes the prepared request through the configurator's mock router. + * + * @param uri - The fully resolved request URL. + * @param init - The prepared fetch options. + * @returns The router's response observable input. + */ + protected override _performFetch(uri: string, init: RequestInit): ObservableInput { + return router.resolve(uri, init); + } + }; +} diff --git a/packages/modules/http/src/mock/index.ts b/packages/modules/http/src/mock/index.ts new file mode 100644 index 0000000000..581840e90d --- /dev/null +++ b/packages/modules/http/src/mock/index.ts @@ -0,0 +1,29 @@ +/** + * Test doubles for the HTTP module. + * + * @remarks + * Imported from `@equinor/fusion-framework-module-http/mock`, so the mock ships + * and versions with the implementation it stands in for. + * + * The mock replaces only the network call each client makes — everything + * around it (request preparation, MSAL scope handling, the response + * pipeline) is the real `HttpClientMsal`, so a test still exercises how the + * application builds and uses its clients, not just canned data. + * + * This entry point has no dependency on any test runner. + * + * @packageDocumentation + */ + +export { HttpMockRouter, type HttpMockMiddleware } from './HttpMockRouter'; +export { createHttpClientMockCtor } from './create-http-client-mock-ctor'; +export { HttpMockConfigurator } from './HttpMockConfigurator'; +export { enableHttpMock, httpMockModule, type HttpConfigMockFn } from './module'; +export { + fromExpressStyleHandler, + type ExpressStyleRequest, + MockExpressResponse, + type ExpressStyleResponse, + fromOpenApiMock, + type OpenApiMockLike, +} from './adapters'; diff --git a/packages/modules/http/src/mock/module.ts b/packages/modules/http/src/mock/module.ts new file mode 100644 index 0000000000..6d97f43e6b --- /dev/null +++ b/packages/modules/http/src/mock/module.ts @@ -0,0 +1,56 @@ +import type { IModulesConfigurator } from '@equinor/fusion-framework-module'; + +import { module as httpModule, type HttpMsalModule } from '../module'; + +import { HttpMockConfigurator } from './HttpMockConfigurator'; + +/** + * The HTTP module with every client answering requests from registered route + * handlers instead of the network. + * + * @remarks + * Only `configure` differs from the real module, so the provider, the schema + * and the initialization flow stay exactly as they are in production. + */ +export const httpMockModule: HttpMsalModule = { + ...httpModule, + configure: () => new HttpMockConfigurator(), +}; + +/** + * Configuration callback for {@link enableHttpMock}. + */ +export type HttpConfigMockFn = ( + configurator: HttpMockConfigurator, + ref?: TRef, +) => void; + +/** + * Enables the HTTP module against registered route handlers, so a test needs + * no network and no locally running server. + * + * @remarks + * Registered last, this replaces whichever HTTP 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 register route handlers. + * + * @example + * ```typescript + * enableHttpMock(configurator, (builder) => { + * builder.configureClient('catalog', { baseUri: 'https://api.example.com' }); + * builder.get('/items', () => Response.json([{ id: 1 }])); + * }); + * ``` + */ +export const enableHttpMock = ( + // biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types + configurator: IModulesConfigurator, + configure?: HttpConfigMockFn, +): void => { + configurator.addConfig({ module: httpMockModule, configure } as { + module: HttpMsalModule; + }); +}; diff --git a/packages/modules/http/tests/mock/HttpMockConfigurator.test.ts b/packages/modules/http/tests/mock/HttpMockConfigurator.test.ts new file mode 100644 index 0000000000..44d8b00cff --- /dev/null +++ b/packages/modules/http/tests/mock/HttpMockConfigurator.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { HttpClientProvider } from '../../src/provider'; +import { HttpMockConfigurator } from '../../src/mock/HttpMockConfigurator'; + +describe('HttpMockConfigurator', () => { + it('answers a configured client from a registered handler instead of the network', async () => { + const configurator = new HttpMockConfigurator(); + configurator.configureClient('catalog', { baseUri: 'https://api.example.com' }); + configurator.get('/items', () => Response.json([{ id: 1 }])); + + const provider = new HttpClientProvider(configurator); + const client = provider.createClient('catalog'); + + await expect(client.json('/items')).resolves.toEqual([{ id: 1 }]); + }); + + it('shares one router across every client it builds', async () => { + const configurator = new HttpMockConfigurator(); + configurator.configureClient('a', { baseUri: 'https://a.example.com' }); + configurator.configureClient('b', { baseUri: 'https://b.example.com' }); + configurator.get('/ping', () => Response.json('pong')); + + const provider = new HttpClientProvider(configurator); + + await expect(provider.createClient('a').json('/ping')).resolves.toBe('pong'); + await expect(provider.createClient('b').json('/ping')).resolves.toBe('pong'); + }); + + it('resolves an ad-hoc client (no configureClient call) against the same router', async () => { + const configurator = new HttpMockConfigurator(); + configurator.get('/ping', () => Response.json('pong')); + + const provider = new HttpClientProvider(configurator); + const client = provider.createClient('https://example.com'); + + await expect(client.json('/ping')).resolves.toBe('pong'); + }); + + it('supports .use for a whole-request middleware, alongside .get/.post/...', async () => { + const configurator = new HttpMockConfigurator(); + configurator.configureClient('catalog', { baseUri: 'https://api.example.com' }); + configurator.use((request) => + request.url.includes('/special') ? Response.json('from-use') : undefined, + ); + configurator.get('/items', () => Response.json('from-get')); + + const provider = new HttpClientProvider(configurator); + const client = provider.createClient('catalog'); + + await expect(client.json('/special')).resolves.toBe('from-use'); + await expect(client.json('/items')).resolves.toBe('from-get'); + }); + + it('throws once no registered handler matches, naming the method and URL', async () => { + const configurator = new HttpMockConfigurator(); + configurator.configureClient('catalog', { baseUri: 'https://api.example.com' }); + + const provider = new HttpClientProvider(configurator); + const client = provider.createClient('catalog'); + + await expect(client.json('/missing')).rejects.toThrow(/No mock handler matched/); + }); + + it('drops every registered handler after resetHandlers()', async () => { + const configurator = new HttpMockConfigurator(); + configurator.configureClient('catalog', { baseUri: 'https://api.example.com' }); + configurator.get('/items', () => Response.json('ok')); + + configurator.resetHandlers(); + + const provider = new HttpClientProvider(configurator); + const client = provider.createClient('catalog'); + + await expect(client.json('/items')).rejects.toThrow(/No mock handler matched/); + }); +}); diff --git a/packages/modules/http/tests/mock/HttpMockRouter.test.ts b/packages/modules/http/tests/mock/HttpMockRouter.test.ts new file mode 100644 index 0000000000..93762def7e --- /dev/null +++ b/packages/modules/http/tests/mock/HttpMockRouter.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; + +import { HttpMockRouter } from '../../src/mock/HttpMockRouter'; + +describe('HttpMockRouter', () => { + it('answers a request from a middleware registered with .use', async () => { + const router = new HttpMockRouter(); + router.use(() => Response.json({ ok: true })); + + const response = await router.resolve('http://localhost/anything', {}); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); + + it('runs middleware in registration order, stopping at the first that answers', async () => { + const router = new HttpMockRouter(); + const calls: string[] = []; + router.use(() => { + calls.push('first'); + return undefined; + }); + router.use(() => { + calls.push('second'); + return Response.json({ from: 'second' }); + }); + router.use(() => { + calls.push('third'); + return Response.json({ from: 'third' }); + }); + + const response = await router.resolve('http://localhost/x', {}); + + expect(calls).toEqual(['first', 'second']); + expect(await response.json()).toEqual({ from: 'second' }); + }); + + it('matches .get/.post/.put/.patch/.delete by method', async () => { + const router = new HttpMockRouter(); + router + .get('/items', () => Response.json('get')) + .post('/items', () => Response.json('post')) + .put('/items', () => Response.json('put')) + .patch('/items', () => Response.json('patch')) + .delete('/items', () => Response.json('delete')); + + // Verify every convenience method registers the correct HTTP method. + for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) { + const response = await router.resolve('http://localhost/items', { method }); + expect(await response.json()).toBe(method.toLowerCase()); + } + }); + + it('matches a method case-insensitively', async () => { + const router = new HttpMockRouter(); + router.on('get', '/items', () => Response.json('ok')); + + const response = await router.resolve('http://localhost/items', { method: 'get' }); + + expect(await response.json()).toBe('ok'); + }); + + it('matches a string match as a substring of the resolved URL', async () => { + const router = new HttpMockRouter(); + router.get('/items', () => Response.json('matched')); + + const response = await router.resolve('http://localhost/api/items/42', {}); + await expect(response.json()).resolves.toBe('matched'); + + await expect(router.resolve('http://localhost/api/other', {})).rejects.toThrow( + /No mock handler matched/, + ); + }); + + it('matches a RegExp against the full resolved URL', async () => { + const router = new HttpMockRouter(); + router.get(/\/items\/\d+$/, () => Response.json('matched')); + + await expect((await router.resolve('http://localhost/items/42', {})).json()).resolves.toBe( + 'matched', + ); + await expect(router.resolve('http://localhost/items/abc', {})).rejects.toThrow( + /No mock handler matched/, + ); + }); + + it('throws naming the method and URL when no middleware answers', async () => { + const router = new HttpMockRouter(); + + await expect(router.resolve('http://localhost/missing', { method: 'GET' })).rejects.toThrow( + 'No mock handler matched GET http://localhost/missing.', + ); + }); + + it('clones the request per middleware so each can read the body independently', async () => { + const router = new HttpMockRouter(); + const seenBodies: string[] = []; + router.use(async (request) => { + seenBodies.push(await request.clone().text()); + return undefined; + }); + router.use(async (request) => { + seenBodies.push(await request.text()); + return Response.json({ ok: true }); + }); + + await router.resolve('http://localhost/x', { method: 'POST', body: 'hello' }); + + expect(seenBodies).toEqual(['hello', 'hello']); + }); + + it('forgets every registered middleware after reset()', async () => { + const router = new HttpMockRouter(); + router.use(() => Response.json({ ok: true })); + + router.reset(); + + await expect(router.resolve('http://localhost/x', {})).rejects.toThrow( + /No mock handler matched/, + ); + }); +}); diff --git a/packages/modules/http/tests/mock/adapters.test.ts b/packages/modules/http/tests/mock/adapters.test.ts new file mode 100644 index 0000000000..127c01a484 --- /dev/null +++ b/packages/modules/http/tests/mock/adapters.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { fromExpressStyleHandler } from '../../src/mock/adapters/from-express-style-handler'; +import { MockExpressResponse } from '../../src/mock/adapters/MockExpressResponse'; +import { fromOpenApiMock } from '../../src/mock/adapters/from-open-api-mock'; + +describe('MockExpressResponse', () => { + it('resolves done from .json()', async () => { + const res = new MockExpressResponse(); + res.status(201).json({ id: 1 }); + + const response = await res.done; + + expect(response.status).toBe(201); + expect(response.headers.get('content-type')).toBe('application/json'); + expect(await response.json()).toEqual({ id: 1 }); + }); + + it('resolves done from .send() with a string body, defaulting to a text content-type', async () => { + const res = new MockExpressResponse(); + res.send('plain text'); + + const response = await res.done; + + expect(await response.text()).toBe('plain text'); + expect(response.headers.get('content-type')).toBe('text/plain;charset=UTF-8'); + }); + + it('resolves done from .send() with a non-string body as JSON', async () => { + const res = new MockExpressResponse(); + res.send({ ok: true }); + + const response = await res.done; + + expect(await response.json()).toEqual({ ok: true }); + }); + + it('resolves done from .end() with no body', async () => { + const res = new MockExpressResponse(); + res.status(204).end(); + + const response = await res.done; + + expect(response.status).toBe(204); + expect(await response.text()).toBe(''); + }); + + it('ignores a second terminal call, keeping the first response', async () => { + const res = new MockExpressResponse(); + res.json({ first: true }); + res.json({ second: true }); + + expect(await (await res.done).json()).toEqual({ first: true }); + }); +}); + +describe('fromExpressStyleHandler', () => { + it('maps a Request into { method, path, query, headers, body } for the handler', async () => { + const middleware = fromExpressStyleHandler((req, res) => { + res.status(200).json(req); + }); + + const response = await middleware( + new Request('http://localhost/items/1?verbose=true', { + headers: { 'x-test': 'yes' }, + }), + ); + + expect(await response?.json()).toEqual( + expect.objectContaining({ + method: 'GET', + path: '/items/1', + query: { verbose: 'true' }, + headers: expect.objectContaining({ 'x-test': 'yes' }), + }), + ); + }); + + it('parses a JSON request body before handing it to the handler', async () => { + const middleware = fromExpressStyleHandler((req, res) => { + res.status(200).json({ received: req.body }); + }); + + const response = await middleware( + new Request('http://localhost/items', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'foo' }), + }), + ); + + expect(await response?.json()).toEqual({ received: { name: 'foo' } }); + }); + + it('answers with whatever status/body the handler sets on the response', async () => { + const middleware = fromExpressStyleHandler((_req, res) => { + res.status(404).json({ message: 'not found' }); + }); + + const response = await middleware(new Request('http://localhost/missing')); + + expect(response?.status).toBe(404); + expect(await response?.json()).toEqual({ message: 'not found' }); + }); +}); + +describe('fromOpenApiMock', () => { + it('resolves a matching request into a JSON Response from the OpenApiMock', async () => { + const middleware = fromOpenApiMock({ + resolve: async ({ method, path }) => + method === 'GET' && path === '/pets/1' + ? { status: 200, mock: { id: '1', name: 'Rex' } } + : undefined, + }); + + const response = await middleware(new Request('http://localhost/pets/1')); + + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ id: '1', name: 'Rex' }); + }); + + it('declines (returns undefined) when the OpenApiMock has no matching operation', async () => { + const middleware = fromOpenApiMock({ resolve: async () => undefined }); + + await expect(middleware(new Request('http://localhost/unknown'))).resolves.toBeUndefined(); + }); + + it('forwards query parameters to resolve()', async () => { + const middleware = fromOpenApiMock({ + resolve: async ({ query }) => ({ status: 200, mock: query }), + }); + + const response = await middleware(new Request('http://localhost/items?page=2')); + + expect(await response?.json()).toEqual({ page: '2' }); + }); +}); diff --git a/packages/modules/http/tests/mock/module.test.ts b/packages/modules/http/tests/mock/module.test.ts new file mode 100644 index 0000000000..e2e4b031e0 --- /dev/null +++ b/packages/modules/http/tests/mock/module.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ModulesConfigurator } from '@equinor/fusion-framework-module'; + +import { module as httpModule } from '../../src/module'; +import { HttpMockConfigurator } from '../../src/mock/HttpMockConfigurator'; +import { enableHttpMock, httpMockModule } from '../../src/mock/module'; + +/** Initializes the mock module through the real module system, rather than hand-building a provider. */ +const initializeMockWith = async ( + configure?: (builder: HttpMockConfigurator) => void, +): Promise<{ createClient: (key: string) => { json(path: string): Promise } }> => { + const configurator = new ModulesConfigurator([]); + enableHttpMock(configurator, configure); + const instances = await configurator.initialize(); + // `initialize()` returns instances keyed by module name with no static type for + // application modules; only the `http` client shape this test actually calls is asserted + return ( + instances as unknown as { + http: { createClient: (key: string) => { json(path: string): Promise } }; + } + ).http; +}; + +describe('httpMockModule', () => { + it('matches the real module name and initialize path', () => { + expect(httpMockModule.name).toBe(httpModule.name); + expect(httpMockModule.initialize).toBe(httpModule.initialize); + }); + + it('builds an HttpMockConfigurator', async () => { + const configurator = await httpMockModule.configure?.(); + + expect(configurator).toBeInstanceOf(HttpMockConfigurator); + }); +}); + +describe('enableHttpMock', () => { + it('answers a configured client from a registered handler, resolved through the module system', async () => { + const http = await initializeMockWith((builder) => { + builder.configureClient('catalog', { baseUri: 'https://api.example.com' }); + builder.get('/items', () => Response.json([{ id: 1 }])); + }); + + await expect(http.createClient('catalog').json('/items')).resolves.toEqual([{ id: 1 }]); + }); + + it('replaces an already registered http module', async () => { + const configurator = new ModulesConfigurator([httpModule]); + enableHttpMock(configurator, (builder) => { + builder.configureClient('catalog', { baseUri: 'https://api.example.com' }); + builder.get('/items', () => Response.json('mocked')); + }); + + // same untyped-instances reason as `initializeMockWith` above + const instances = (await configurator.initialize()) as unknown as { + http: { createClient: (key: string) => { json(path: string): Promise } }; + }; + + await expect(instances.http.createClient('catalog').json('/items')).resolves.toBe('mocked'); + }); +}); + +describe('vi.fn overrides', () => { + it('lets a handler be a vi.fn spy, asserting it was called', async () => { + const handler = vi.fn(() => Response.json('spied')); + const http = await initializeMockWith((builder) => { + builder.configureClient('catalog', { baseUri: 'https://api.example.com' }); + builder.get('/items', handler); + }); + + await expect(http.createClient('catalog').json('/items')).resolves.toBe('spied'); + expect(handler).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/modules/module/src/__tests__/configurator/ModulesConfigurator.test.ts b/packages/modules/module/src/__tests__/configurator/ModulesConfigurator.test.ts index df57084f02..4471244d11 100644 --- a/packages/modules/module/src/__tests__/configurator/ModulesConfigurator.test.ts +++ b/packages/modules/module/src/__tests__/configurator/ModulesConfigurator.test.ts @@ -24,6 +24,51 @@ describe('ModulesConfigurator', () => { expect(configurator.modules.filter((m) => m === mod)).toHaveLength(1); }); + it('replaces a previously registered module with the same name', () => { + const configurator = new ModulesConfigurator(); + const original = createMockModule('alpha'); + const replacement = createMockModule('alpha'); + configurator.addConfig({ module: original }); + configurator.addConfig({ module: replacement }); + + expect(configurator.modules).toHaveLength(1); + expect(configurator.modules[0]).toBe(replacement); + }); + + it('replaces previous module callbacks when the same module name is registered again', async () => { + const configurator = new ModulesConfigurator(); + const original = createMockModule('alpha'); + const replacement = createMockModule('alpha'); + const configureSpy = vi.fn(); + const configureSpy2 = vi.fn(); + const afterConfigSpy = vi.fn(); + const afterConfigSpy2 = vi.fn(); + const afterInitSpy = vi.fn(); + const afterInitSpy2 = vi.fn(); + + configurator.addConfig({ + module: original, + configure: configureSpy, + afterConfig: afterConfigSpy, + afterInit: afterInitSpy, + }); + configurator.addConfig({ + module: replacement, + configure: configureSpy2, + afterConfig: afterConfigSpy2, + afterInit: afterInitSpy2, + }); + + await configurator.initialize(); + + expect(configureSpy).not.toHaveBeenCalled(); + expect(configureSpy2).toHaveBeenCalledOnce(); + expect(afterConfigSpy).not.toHaveBeenCalled(); + expect(afterConfigSpy2).toHaveBeenCalledOnce(); + expect(afterInitSpy).not.toHaveBeenCalled(); + expect(afterInitSpy2).toHaveBeenCalledOnce(); + }); + it('wires configure callback into the configure phase', async () => { const configurator = new ModulesConfigurator(); const mod = createMockModule('alpha', { x: 1 }); diff --git a/packages/modules/module/src/__tests__/utils/dot-path.test.ts b/packages/modules/module/src/__tests__/utils/dot-path.test.ts new file mode 100644 index 0000000000..c797c10de6 --- /dev/null +++ b/packages/modules/module/src/__tests__/utils/dot-path.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import type { DotPath, DotPathType } from '../../utils/dot-path.js'; + +type Config = { + required: { nested: string }; + optional?: { nested: string; deeper?: { leaf: number } }; + scalar?: string; +}; + +describe('DotPath', () => { + it('reaches into a required object property', () => { + const path: DotPath = 'required.nested'; + + expect(path).toBe('required.nested'); + }); + + it('reaches into an optional object property', () => { + // An optional property is `T | undefined`, which does not extend `object` — + // without unwrapping it, nothing under an optional branch is reachable + const nested: DotPath = 'optional.nested'; + const deeper: DotPath = 'optional.deeper.leaf'; + + expect([nested, deeper]).toEqual(['optional.nested', 'optional.deeper.leaf']); + }); + + it('resolves the type at a path under an optional property', () => { + const leaf: DotPathType = 1; + const nested: DotPathType = 'a'; + + expect([leaf, nested]).toEqual([1, 'a']); + }); + + it('invents no paths under a scalar', () => { + // @ts-expect-error a string carries no dot-paths of its own + const invalid: DotPath = 'scalar.length'; + + expect(invalid).toBe('scalar.length'); + }); +}); diff --git a/packages/modules/module/src/lib/configurator/ModulesConfigurator.ts b/packages/modules/module/src/lib/configurator/ModulesConfigurator.ts index f53f307110..c02e7f03cf 100644 --- a/packages/modules/module/src/lib/configurator/ModulesConfigurator.ts +++ b/packages/modules/module/src/lib/configurator/ModulesConfigurator.ts @@ -19,6 +19,18 @@ import type { IModulesConfigurator, ModulesConfiguratorConfigCallback, } from './types.js'; + +type QualifiedConfigCallback = ModulesConfiguratorConfigCallback & { + moduleName?: string; +}; + +type QualifiedPostConfigCallback = ((config: any) => void | Promise) & { + moduleName?: string; +}; + +type QualifiedPostInitCallback = ((instance: any) => void | Promise) & { + moduleName?: string; +}; import type { FrameworkPluginCallback, FrameworkPluginTeardown } from '../plugin/index.js'; import { runConfigurePhase } from './phases/run-configure-phase.js'; @@ -115,7 +127,7 @@ export class ModulesConfigurator< * Each entry is added by {@link addConfig} when a `configure` callback is provided. * @protected */ - protected _configs: Array> = []; + protected _configs: Array> = []; /** * Registered post-configure callbacks. @@ -128,7 +140,7 @@ export class ModulesConfigurator< * inspects the config shape itself, it only forwards it at call time. * @protected */ - protected _afterConfiguration: Array<(config: any) => void | Promise> = []; + protected _afterConfiguration: Array = []; /** * Registered post-initialize callbacks. @@ -139,7 +151,7 @@ export class ModulesConfigurator< * internal dispatch; concrete instance types are known at registration but not stored. * @protected */ - protected _afterInit: Array<(instance: any) => void | Promise> = []; + protected _afterInit: Array = []; /** * Registered plugin callbacks. @@ -174,7 +186,38 @@ export class ModulesConfigurator< * @param modules - Optional array of module descriptors to pre-register. */ constructor(modules?: Array) { - this._modules = new Set(modules); + this._modules = new Set(modules ? this._dedupeModulesByName(modules) : []); + } + + /** + * Keeps the last registration for each module name. + * + * @param modules - Module descriptors to deduplicate. + * @returns The deduplicated module descriptors. + */ + private _dedupeModulesByName(modules: Array): Array { + const lastByName = new Map(); + // Iterate in registration order so later descriptors intentionally override earlier ones. + for (const module of modules) { + lastByName.set(module.name, module); + } + return Array.from(lastByName.values()); + } + + /** + * Removes lifecycle callbacks belonging to a replaced module. + * + * @param moduleName - Name of the module whose callbacks are removed. + */ + private _removeModuleCallbacks(moduleName: string): void { + // Remove callbacks from each lifecycle phase so replaced modules cannot run stale behavior. + this._configs = this._configs.filter((callback) => callback.moduleName !== moduleName); + // Keep cleanup callbacks aligned with the module replacement. + this._afterConfiguration = this._afterConfiguration.filter( + (callback) => callback.moduleName !== moduleName, + ); + // Remove initialization callbacks as well, preventing the old module from being initialized. + this._afterInit = this._afterInit.filter((callback) => callback.moduleName !== moduleName); } /** @@ -204,6 +247,9 @@ export class ModulesConfigurator< /** * Registers a single module configurator. * + * If a module with the same `name` was already registered, the previous + * registration is replaced so the last added module wins. + * * Adds the module to the known module set and registers the optional * `configure`, `afterConfig`, and `afterInit` callbacks into their * respective lifecycle phase arrays. @@ -216,7 +262,23 @@ export class ModulesConfigurator< config: IModuleConfigurator, ): void { const { module, afterConfig, afterInit, configure } = config; - this._modules.add(module); + // Find an existing descriptor so re-registering a name can replace all of its lifecycle hooks. + const existingModule = Array.from(this._modules).find((m) => m.name === module.name); + + // Re-registration must remove old callbacks before installing the replacement. + if (existingModule) { + this._removeModuleCallbacks(module.name); + // Replace the descriptor only when the caller supplied a different object. + if (existingModule !== module) { + const modules = Array.from(this._modules) + // Preserve every descriptor while substituting the newly registered module. + .map((m) => (m.name === module.name ? module : m)); + this._modules = new Set(modules); + } + } else { + this._modules.add(module); + } + this._registerEvent({ level: ModuleEventLevel.Debug, name: ModuleConfiguratorEventName.ModuleConfigAdded, @@ -229,12 +291,30 @@ export class ModulesConfigurator< afterInit: !!afterInit, }, }); - // Register each optional callback into its corresponding lifecycle phase array - if (configure) this._configs.push((cfg, ref) => configure(cfg[module.name], ref)); - // Register the afterConfig callback, if provided - if (afterConfig) this._afterConfiguration.push((cfg) => afterConfig(cfg[module.name])); - // Register the afterInit callback, if provided - if (afterInit) this._afterInit.push((instances) => afterInit(instances[module.name])); + // Register each optional callback into its corresponding lifecycle phase array. + // When the same module name is re-registered, previous callbacks are removed + // so the latest configuration wins. + if (configure) { + const callback = ((cfg, ref) => + configure(cfg[module.name], ref)) as QualifiedConfigCallback; + callback.moduleName = module.name; + this._configs.push(callback); + } + + // Register the afterConfig callback, if provided. + if (afterConfig) { + const callback = ((cfg) => afterConfig(cfg[module.name])) as QualifiedPostConfigCallback; + callback.moduleName = module.name; + this._afterConfiguration.push(callback); + } + + // Register the afterInit callback, if provided. + if (afterInit) { + const callback = ((instances) => + afterInit(instances[module.name])) as QualifiedPostInitCallback; + callback.moduleName = module.name; + this._afterInit.push(callback); + } } /** diff --git a/packages/modules/module/src/utils/dot-path.ts b/packages/modules/module/src/utils/dot-path.ts index 8ff7b8bd0a..45b3986a45 100644 --- a/packages/modules/module/src/utils/dot-path.ts +++ b/packages/modules/module/src/utils/dot-path.ts @@ -30,12 +30,12 @@ export type DotPath = Depth exte TObject extends any[] ? `${number}` | `${number}.${DotPath}` : { - [Key in keyof Required & string]: TObject[Key] extends object - ? - | `${Key}` - | (TObject[Key] extends null | undefined - ? never - : `${Key}.${DotPath, Rest>}`) + // `NonNullable` because an optional property is `T | undefined`, which + // does not extend `object` — without it, nothing under an optional + // branch of a configuration is reachable. `DotPathType` already + // resolves such paths, so the two agree only when this does too. + [Key in keyof Required & string]: NonNullable extends object + ? `${Key}` | `${Key}.${DotPath, Rest>}` : `${Key}`; }[keyof Required & string] : never diff --git a/packages/modules/msal/README.md b/packages/modules/msal/README.md index ce6049f934..d551caa74f 100644 --- a/packages/modules/msal/README.md +++ b/packages/modules/msal/README.md @@ -1,3 +1,4 @@ +# `@equinor/fusion-framework-module-msal` `@equinor/fusion-framework-module-msal` provides secure Azure AD authentication for browser applications using Microsoft's MSAL (Microsoft Authentication Library). Perfect for web applications, SPAs, and React apps that need to authenticate with Microsoft services. @@ -78,92 +79,16 @@ try { > [!IMPORTANT] > The `@equinor/fusion-framework-app` enables this package by default, so applications using the app package do not need to enable this module manually. -## Backend-Issued Auth Code Flow +## Documentation -Enable automatic sign-in using a backend-issued authorization code without interactive login prompts. - -### Overview - -When your backend authenticates a user and generates a short-lived SPA auth code, the MSAL module can exchange it for tokens during initialization, eliminating double-login issues and providing seamless authentication. - -### Usage - -```typescript -import { enableMSAL } from '@equinor/fusion-framework-module-msal'; - -enableMSAL(configurator, (builder) => { - builder.setClientConfig({ - auth: { - clientId: 'your-client-id', - tenantId: 'your-tenant-id' - } - }); - - // Backend injects auth code as window.MSAL_AUTH_CODE during initial first page load - // This is the most secure approach - only available on first render, cleared after use - if (typeof window !== 'undefined' && window.MSAL_AUTH_CODE) { - builder.setAuthCode(window.MSAL_AUTH_CODE); - delete (window as any).MSAL_AUTH_CODE; // Clear after consuming - } - - builder.setRequiresAuth(true); -}); -``` - -### How It Works - -1. Backend authenticates user and generates short-lived auth code -2. Frontend passes auth code to MSAL: `builder.setAuthCode(authCode)` -3. During `initialize()`: auth code exchanged for tokens (before `requiresAuth` check) -4. Tokens cached by MSAL → user automatically signed in -5. Falls back to standard MSAL flows on exchange failure - -### API: `setAuthCode(authCode?: string)` - -Sets backend-issued auth code for token exchange during initialization. - -Pass `undefined` to clear/reset a previously configured auth code. - -**Returns:** configurator instance (chainable) - -**Behavior:** -- Exchange happens before `requiresAuth` check -- On success: user auto-authenticated, no login prompt -- On failure: falls back to standard MSAL login -- Auth code cleared after exchange (no reuse) -- `setAuthCode(undefined)` clears configured auth code -- `setAuthCode('')` is treated as absent auth code -- `setAuthCode(' ')` is trimmed and treated as absent auth code -- No auth-code exchange is attempted when auth code is absent/cleared - -**Example:** - -```typescript -// Best practice: Backend injects auth code on initial page load as window.MSAL_AUTH_CODE -if (typeof window !== 'undefined' && window.MSAL_AUTH_CODE) { - builder.setAuthCode(window.MSAL_AUTH_CODE); - delete (window as any).MSAL_AUTH_CODE; // Clear after consuming to prevent reuse -} - -// Clear/reset auth code when input is missing -builder.setAuthCode(undefined); -``` - -### Security - -- ✅ Auth codes: single-use, short-lived (5-10 min) -- ✅ MSAL validates tokens from Microsoft authority -- ✅ Tokens stored securely, refresh tokens auto-managed -- ⚠️ Pass codes securely: HTTPS, HTTP-only cookies, or encrypted channels - -### Troubleshooting - -| Issue | Solution | -|-------|----------| -| Auth code not exchanged | Verify `setAuthCode()` called before init | -| Invalid auth code error | Confirm backend `WithSpaAuthCode` enabled, code is fresh | -| Still shows login prompt | Check auth code exchange completes before `requiresAuth` check | -| Exchange fails | Auth code may have expired; backend should generate fresh code per load | +| Guide | Covers | +| --- | --- | +| [Configuration](./docs/api-reference.md) | `enableMSAL`, builder methods, `IMsalProvider`, and type definitions | +| [Backend-Issued Auth Code Flow](./docs/auth-code-flow.md) | Signing a user in from a backend-issued SPA auth code, without an interactive prompt | +| [Testing](./docs/testing.md) | The `/mock` entry point: in-process authentication, deterministic tokens, and spying | +| [Version Management](./docs/version-management.md) | Version resolution, compatibility checking, and related errors | +| [Migration v2 to v4](./docs/migration-v2-to-v4.md) | Moving from MSAL Browser v2 to v4, including the compatibility proxy | +| [Troubleshooting](./docs/troubleshooting.md) | Common failures and where to get help | ## Configuration @@ -193,92 +118,21 @@ AZURE_TENANT_ID=your-tenant-id AZURE_REDIRECT_URI=https://your-app.com/callback ``` -## API Reference - -### `enableMSAL(configurator, configure?)` - -Enables the MSAL module in your Fusion Framework application. - -**Parameters:** -- `configurator`: `IModulesConfigurator` - The modules configurator instance -- `configure?`: `(builder: { setClientConfig, setRequiresAuth }) => void` - Optional configuration function - -**Returns:** `void` - -**Example:** -```typescript -enableMSAL(configurator, (builder) => { - builder.setClientConfig({ auth: { clientId: '...', tenantId: '...' } }); - builder.setRequiresAuth(true); -}); -``` - -### Type Definitions +## Testing -#### `LoginOptions` +Import from `@equinor/fusion-framework-module-msal/mock` to authenticate in-process instead of against Entra ID. The real configurator, provider and schema validation still run — only the client that would contact Entra ID is substituted. ```typescript -type LoginOptions = { - request: PopupRequest | RedirectRequest; // MSAL request object - behavior?: 'popup' | 'redirect'; // Auth method (default: 'redirect') - silent?: boolean; // Attempt silent auth first (default: true) -}; -``` - -#### `LogoutOptions` +import { enableMsalMock } from '@equinor/fusion-framework-module-msal/mock'; -```typescript -type LogoutOptions = { - redirectUri?: string; // Redirect after logout - account?: AccountInfo; // Account to logout (defaults to active) -}; +enableMsalMock(configurator); ``` -#### `AcquireTokenOptions` +A user named `Test User` is signed in, and tokens are real JWTs minted in-process with a fixed issue time, so they are identical across runs and machines. -```typescript -type AcquireTokenOptions = { - request: PopupRequest | RedirectRequest; // MSAL request with scopes - behavior?: 'popup' | 'redirect'; // Auth method (default: 'redirect') - silent?: boolean; // Attempt silent first (default: true if account available) -}; -``` - -### `IMsalProvider` - -The authentication provider interface available at `framework.auth`: - -```typescript -interface IMsalProvider { - // The MSAL PublicClientApplication instance - readonly client: IMsalClient; - - // Current user account information - readonly account: AccountInfo | null; - - // Initialize the MSAL provider - initialize(): Promise; - - // Acquire an access token for the specified scopes - acquireAccessToken(options: AcquireTokenOptionsLegacy): Promise; - - // Acquire full authentication result - acquireToken(options: AcquireTokenOptionsLegacy): Promise; - - // Login user interactively - login(options: LoginOptions): Promise; - - // Logout user (returns boolean) - logout(options?: LogoutOptions): Promise; - - // Handle authentication redirect (returns AuthenticationResult | null) - handleRedirect(): Promise; -} - -// Note: defaultAccount and other deprecated v2 properties are available only -// when using a v2-compatible proxy via createProxyProvider() -``` +The entry point has **no test-runner dependency**, and ships no mocking API of its own — spying on a call is your test runner's job. +See [Testing](./docs/testing.md) for choosing the signed-in user, signed-out behaviour and runner guidance, or [`@equinor/fusion-framework/mock`](../../framework/docs/testing.md) to mock every framework boundary at once. ## Module Hoisting @@ -287,208 +141,6 @@ The module implements a hoisting pattern where the authentication provider is cr > [!IMPORTANT] > **Configure the auth module only in the root Fusion Framework instance** - Sub-instances will automatically inherit the authentication configuration from the parent. -## Migration Guide - -### MSAL v2 to v4 Migration - -This package has been upgraded from MSAL Browser v2 to v4, providing the latest security improvements and features from Microsoft. - -#### What Changed in v4 - -**New MSAL Browser v4 Features:** -- Enhanced security with improved token management -- Better performance and memory usage -- New authentication API structure with nested request objects -- Improved error handling and retry mechanisms - -**Architecture Changes:** -- **Module Hoisting**: The module uses module hoisting, meaning sub-module instances proxy the parent module instance -- **Shared Authentication State**: Authentication state is shared across all module instances -- **Async Initialization**: New `initialize()` method must be called before using the provider - -#### Breaking Changes - -1. **Auto-initialization via Framework** - ```typescript - // The provider initializes automatically when framework loads - const framework = await initialize(configurator); - const auth = framework.auth; // Already initialized - - // Manual initialization is only needed for standalone usage - const provider = new MsalProvider(config); - await provider.initialize(); - ``` - -2. **API Method Signature Updates** - - `logout()` now returns `Promise` instead of `Promise` - - `handleRedirect()` now returns `Promise` instead of `Promise` - - Methods now expect nested request objects (v4 format) - -3. **Account Property Changes** - - Use `account` property (returns `AccountInfo | null`) - v4 native - - `defaultAccount` is deprecated and only available via v2 proxy layer - - Migration: Replace `defaultAccount` with `account` throughout your code - -#### Migration Steps - -1. **Update Token Acquisition** (Recommended) - ```typescript - // Before (v2 format - still works via proxy) - const token = await framework.auth.acquireAccessToken({ - scopes: ['api.read'] - }); - - // After (v4 format - recommended) - const token = await framework.auth.acquireAccessToken({ - request: { scopes: ['api.read'] } - }); - ``` - -2. **Update Logout Handling** - ```typescript - // Before - await framework.auth.logout(); - - // After (check return value) - const success = await framework.auth.logout(); - if (success) { - // Handle successful logout - } - ``` - -3. **Update Redirect Handling** - ```typescript - // Before - await framework.auth.handleRedirect(); - - // After (handle result) - const result = await framework.auth.handleRedirect(); - if (result?.account) { - // User authenticated successfully - console.log('Logged in as:', result.account.username); - } - ``` - -4. **Update Configuration** (if needed) - ```typescript - // Ensure only the root module configures MSAL - enableMSAL(configurator, (builder) => { - builder.setClientConfig({ - auth: { - clientId: 'your-client-id', - tenantId: 'your-tenant-id', - redirectUri: 'https://your-app.com/callback' - } - }); - builder.setRequiresAuth(true); - }); - ``` - -5. **Remove Duplicate Configurations**: Remove MSAL configuration from child modules - -#### Backward Compatibility - -The module includes a **v2 proxy layer** that automatically converts v2 API calls to v4 format. This means: -- ✅ Existing code continues to work without changes -- ✅ Legacy format `{ scopes: [] }` is still supported -- ✅ Deprecated v2 properties like `defaultAccount` are available via v2 proxy (with deprecation warnings) -- ⚠️ New v4 features require using v4 format - -#### Benefits of Migration - -- **Better Security**: Latest MSAL v4 security improvements and token handling -- **Improved Performance**: Faster token acquisition, better caching, reduced memory usage -- **Enhanced Error Handling**: More robust error recovery and retry mechanisms -- **Future-Proof**: Access to latest Microsoft authentication features and updates -- **Shared State**: Improved authentication state management across app scopes via module hoisting -- **Better Developer Experience**: Cleaner API, better TypeScript support, comprehensive documentation - -## Troubleshooting - -### Common Issues - -| Issue | Solution | -|-------|----------| -| **Authentication Loop** | Ensure redirect URIs match your application's routing | -| **Token Acquisition Fails** | Check that required scopes are properly configured | -| **Module Not Found** | Ensure the module is properly configured and framework is initialized | -| **Multiple MSAL Instances** | Remove duplicate configurations from child modules | -| **Redirect Returns Void** | For redirect flows, use `handleRedirect()` after navigation completes | -| **Token Empty/Undefined** | Verify user is authenticated and scopes are correct | - -### Getting Help - -- 📖 [MSAL Cookbook](https://github.com/equinor/fusion-framework/tree/main/cookbooks/app-react-msal) - Complete working examples -- 🐛 [Report Issues](https://github.com/equinor/fusion/issues) - Bug reports and feature requests - -## Version Management - -The MSAL module includes built-in version checking to ensure compatibility between different MSAL library versions. - -### Version Resolution - -```typescript -import { resolveVersion, VersionError } from '@equinor/fusion-framework-module-msal/versioning'; - -// Resolve and validate a version -const result = resolveVersion('2.0.0'); -console.log(result.isLatest); // false -console.log(result.satisfiesLatest); // true -console.log(result.enumVersion); // MsalModuleVersion.V2 -``` - -### Version Checking Behavior - -- **Major Version Incompatibility**: Throws `VersionError` if requested major version is greater than latest -- **Minor Version Mismatch**: Logs warning but allows execution -- **Patch Differences**: Ignored for compatibility -- **Invalid Versions**: Throws `VersionError` with descriptive message - -### API Reference - -#### `resolveVersion(version: string | SemVer): ResolvedVersion` - -Resolves and validates a version string against the latest available MSAL version. - -**Parameters:** -- `version` - Version string or SemVer object to resolve - -**Returns:** `ResolvedVersion` object containing: -- `wantedVersion: SemVer` - The parsed requested version -- `latestVersion: SemVer` - The latest available version -- `isLatest: boolean` - Whether the version is exactly the latest -- `satisfiesLatest: boolean` - Whether the major version matches latest -- `enumVersion: MsalModuleVersion` - Corresponding enum version - -**Throws:** `VersionError` for invalid or incompatible versions - -#### `VersionError` - -Error class for version-related issues with the following types: -- `InvalidVersion` - Requested version is not a valid semver -- `InvalidLatestVersion` - Latest version parsing failed (build issue) -- `MajorIncompatibility` - Major version is greater than latest -- `MinorMismatch` - Minor version differs (warning only) -- `PatchDifference` - Patch version differs (info only) -- `IncompatibleVersion` - General incompatibility - -### Error Handling - -```typescript -import { resolveVersion, VersionError } from '@equinor/fusion-framework-module-msal/versioning'; - -try { - const result = resolveVersion('3.0.0'); // Assuming latest is 2.x -} catch (error) { - if (error instanceof VersionError) { - console.error('Version error:', error.message); - console.error('Requested:', error.requestedVersion); - console.error('Latest:', error.latestVersion); - console.error('Type:', error.type); - } -} -``` - ## Additional Resources ### Official Documentation @@ -507,6 +159,3 @@ try { - 💬 For questions: [Fusion Framework Discussions](https://github.com/equinor/fusion-framework/discussions) - 🐛 Report bugs: [Fusion Framework Issues](https://github.com/equinor/fusion-framework/issues) - 📧 Contact: Equinor Fusion Framework Team - - - diff --git a/packages/modules/msal/docs/api-reference.md b/packages/modules/msal/docs/api-reference.md new file mode 100644 index 0000000000..358467dbb2 --- /dev/null +++ b/packages/modules/msal/docs/api-reference.md @@ -0,0 +1,85 @@ +# MSAL API Reference + +## `enableMSAL(configurator, configure?)` + +Enables the MSAL module in your Fusion Framework application. + +**Parameters:** +- `configurator`: `IModulesConfigurator` - The modules configurator instance +- `configure?`: `(builder: { setClientConfig, setRequiresAuth }) => void` - Optional configuration function + +**Returns:** `void` + +**Example:** +```typescript +enableMSAL(configurator, (builder) => { + builder.setClientConfig({ auth: { clientId: '...', tenantId: '...' } }); + builder.setRequiresAuth(true); +}); +``` + +## Type Definitions + +### `LoginOptions` + +```typescript +type LoginOptions = { + request: PopupRequest | RedirectRequest; // MSAL request object + behavior?: 'popup' | 'redirect'; // Auth method (default: 'redirect') + silent?: boolean; // Attempt silent auth first (default: true) +}; +``` + +### `LogoutOptions` + +```typescript +type LogoutOptions = { + redirectUri?: string; // Redirect after logout + account?: AccountInfo; // Account to logout (defaults to active) +}; +``` + +### `AcquireTokenOptions` + +```typescript +type AcquireTokenOptions = { + request: PopupRequest | RedirectRequest; // MSAL request with scopes + behavior?: 'popup' | 'redirect'; // Auth method (default: 'redirect') + silent?: boolean; // Attempt silent first (default: true if account available) +}; +``` + +## `IMsalProvider` + +The authentication provider interface available at `framework.auth`: + +```typescript +interface IMsalProvider { + // The MSAL PublicClientApplication instance + readonly client: IMsalClient; + + // Current user account information + readonly account: AccountInfo | null; + + // Initialize the MSAL provider + initialize(): Promise; + + // Acquire an access token for the specified scopes + acquireAccessToken(options: AcquireTokenOptionsLegacy): Promise; + + // Acquire full authentication result + acquireToken(options: AcquireTokenOptionsLegacy): Promise; + + // Login user interactively + login(options: LoginOptions): Promise; + + // Logout user (returns boolean) + logout(options?: LogoutOptions): Promise; + + // Handle authentication redirect (returns AuthenticationResult | null) + handleRedirect(): Promise; +} + +// Note: defaultAccount and other deprecated v2 properties are available only +// when using a v2-compatible proxy via createProxyProvider() +``` diff --git a/packages/modules/msal/docs/auth-code-flow.md b/packages/modules/msal/docs/auth-code-flow.md new file mode 100644 index 0000000000..f0d9e0d27b --- /dev/null +++ b/packages/modules/msal/docs/auth-code-flow.md @@ -0,0 +1,86 @@ +# Backend-Issued Auth Code Flow + +Enable automatic sign-in using a backend-issued authorization code without interactive login prompts. + +## Overview + +When your backend authenticates a user and generates a short-lived SPA auth code, the MSAL module can exchange it for tokens during initialization, eliminating double-login issues and providing seamless authentication. + +## Usage + +```typescript +import { enableMSAL } from '@equinor/fusion-framework-module-msal'; + +enableMSAL(configurator, (builder) => { + builder.setClientConfig({ + auth: { + clientId: 'your-client-id', + tenantId: 'your-tenant-id' + } + }); + + // Backend injects auth code as window.MSAL_AUTH_CODE during initial first page load + // This is the most secure approach - only available on first render, cleared after use + if (typeof window !== 'undefined' && window.MSAL_AUTH_CODE) { + builder.setAuthCode(window.MSAL_AUTH_CODE); + delete (window as any).MSAL_AUTH_CODE; // Clear after consuming + } + + builder.setRequiresAuth(true); +}); +``` + +## How It Works + +1. Backend authenticates user and generates short-lived auth code +2. Frontend passes auth code to MSAL: `builder.setAuthCode(authCode)` +3. During `initialize()`: auth code exchanged for tokens (before `requiresAuth` check) +4. Tokens cached by MSAL → user automatically signed in +5. Falls back to standard MSAL flows on exchange failure + +## API: `setAuthCode(authCode?: string)` + +Sets backend-issued auth code for token exchange during initialization. + +Pass `undefined` to clear/reset a previously configured auth code. + +**Returns:** configurator instance (chainable) + +**Behavior:** +- Exchange happens before `requiresAuth` check +- On success: user auto-authenticated, no login prompt +- On failure: falls back to standard MSAL login +- Auth code cleared after exchange (no reuse) +- `setAuthCode(undefined)` clears configured auth code +- `setAuthCode('')` is treated as absent auth code +- `setAuthCode(' ')` is trimmed and treated as absent auth code +- No auth-code exchange is attempted when auth code is absent/cleared + +**Example:** + +```typescript +// Best practice: Backend injects auth code on initial page load as window.MSAL_AUTH_CODE +if (typeof window !== 'undefined' && window.MSAL_AUTH_CODE) { + builder.setAuthCode(window.MSAL_AUTH_CODE); + delete (window as any).MSAL_AUTH_CODE; // Clear after consuming to prevent reuse +} + +// Clear/reset auth code when input is missing +builder.setAuthCode(undefined); +``` + +## Security + +- ✅ Auth codes: single-use, short-lived (5-10 min) +- ✅ MSAL validates tokens from Microsoft authority +- ✅ Tokens stored securely, refresh tokens auto-managed +- ⚠️ Pass codes securely: HTTPS, HTTP-only cookies, or encrypted channels + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| Auth code not exchanged | Verify `setAuthCode()` called before init | +| Invalid auth code error | Confirm backend `WithSpaAuthCode` enabled, code is fresh | +| Still shows login prompt | Check auth code exchange completes before `requiresAuth` check | +| Exchange fails | Auth code may have expired; backend should generate fresh code per load | diff --git a/packages/modules/msal/docs/migration-v2-to-v4.md b/packages/modules/msal/docs/migration-v2-to-v4.md new file mode 100644 index 0000000000..3392cd731b --- /dev/null +++ b/packages/modules/msal/docs/migration-v2-to-v4.md @@ -0,0 +1,115 @@ +# Migration Guide + +## MSAL v2 to v4 Migration + +This package has been upgraded from MSAL Browser v2 to v4, providing the latest security improvements and features from Microsoft. + +### What Changed in v4 + +**New MSAL Browser v4 Features:** +- Enhanced security with improved token management +- Better performance and memory usage +- New authentication API structure with nested request objects +- Improved error handling and retry mechanisms + +**Architecture Changes:** +- **Module Hoisting**: The module uses module hoisting, meaning sub-module instances proxy the parent module instance +- **Shared Authentication State**: Authentication state is shared across all module instances +- **Async Initialization**: New `initialize()` method must be called before using the provider + +### Breaking Changes + +1. **Auto-initialization via Framework** + ```typescript + // The provider initializes automatically when framework loads + const framework = await initialize(configurator); + const auth = framework.auth; // Already initialized + + // Manual initialization is only needed for standalone usage + const provider = new MsalProvider(config); + await provider.initialize(); + ``` + +2. **API Method Signature Updates** + - `logout()` now returns `Promise` instead of `Promise` + - `handleRedirect()` now returns `Promise` instead of `Promise` + - Methods now expect nested request objects (v4 format) + +3. **Account Property Changes** + - Use `account` property (returns `AccountInfo | null`) - v4 native + - `defaultAccount` is deprecated and only available via v2 proxy layer + - Migration: Replace `defaultAccount` with `account` throughout your code + +### Migration Steps + +1. **Update Token Acquisition** (Recommended) + ```typescript + // Before (v2 format - still works via proxy) + const token = await framework.auth.acquireAccessToken({ + scopes: ['api.read'] + }); + + // After (v4 format - recommended) + const token = await framework.auth.acquireAccessToken({ + request: { scopes: ['api.read'] } + }); + ``` + +2. **Update Logout Handling** + ```typescript + // Before + await framework.auth.logout(); + + // After (check return value) + const success = await framework.auth.logout(); + if (success) { + // Handle successful logout + } + ``` + +3. **Update Redirect Handling** + ```typescript + // Before + await framework.auth.handleRedirect(); + + // After (handle result) + const result = await framework.auth.handleRedirect(); + if (result?.account) { + // User authenticated successfully + console.log('Logged in as:', result.account.username); + } + ``` + +4. **Update Configuration** (if needed) + ```typescript + // Ensure only the root module configures MSAL + enableMSAL(configurator, (builder) => { + builder.setClientConfig({ + auth: { + clientId: 'your-client-id', + tenantId: 'your-tenant-id', + redirectUri: 'https://your-app.com/callback' + } + }); + builder.setRequiresAuth(true); + }); + ``` + +5. **Remove Duplicate Configurations**: Remove MSAL configuration from child modules + +### Backward Compatibility + +The module includes a **v2 proxy layer** that automatically converts v2 API calls to v4 format. This means: +- ✅ Existing code continues to work without changes +- ✅ Legacy format `{ scopes: [] }` is still supported +- ✅ Deprecated v2 properties like `defaultAccount` are available via v2 proxy (with deprecation warnings) +- ⚠️ New v4 features require using v4 format + +### Benefits of Migration + +- **Better Security**: Latest MSAL v4 security improvements and token handling +- **Improved Performance**: Faster token acquisition, better caching, reduced memory usage +- **Enhanced Error Handling**: More robust error recovery and retry mechanisms +- **Future-Proof**: Access to latest Microsoft authentication features and updates +- **Shared State**: Improved authentication state management across app scopes via module hoisting +- **Better Developer Experience**: Cleaner API, better TypeScript support, comprehensive documentation diff --git a/packages/modules/msal/docs/testing.md b/packages/modules/msal/docs/testing.md new file mode 100644 index 0000000000..389ae3f5e7 --- /dev/null +++ b/packages/modules/msal/docs/testing.md @@ -0,0 +1,167 @@ +# MSAL — test double + +Authenticate in-process instead of against Entra ID. + +```typescript +import { enableMsalMock } from '@equinor/fusion-framework-module-msal/mock'; + +enableMsalMock(configurator); +``` + +Import path: `@equinor/fusion-framework-module-msal/mock`. The entry point has **no test-runner dependency**. + +## What is substituted + +> [!IMPORTANT] +> Only `IMsalClient` — the object that would contact Entra ID. The real `MsalConfigurator`, the real `MsalProvider` and the real schema validation all still run. + +That distinction is the point. Scope resolution, silent-first token acquisition, account handling, proxy providers and telemetry stay on the production code path, so a test observes real provider behaviour: + +```typescript +const fusion = await mockFramework((configurator) => { + // the client is configured with *what it talks to*, exactly as in production + configurator.msal.setClientConfig({ auth: { clientId: 'my-app', tenantId: 'my-tenant' } }); +}); + +const token = await fusion.modules.auth.acquireAccessToken(); +// scope is 'my-app/.default' — resolved by the real provider, not by the test double +``` + +A double that replaced the provider would have skipped that logic and reported whatever it was told to. + +## Defaults + +A user named `Test User` is signed in. Tokens are real JWTs, minted in-process with a fixed issue time, so they are identical across runs and machines and can be compared or snapshotted directly. + +When no client configuration is declared, a stand-in one is used, so an application boots under test without credentials it does not have. + +## Choosing the signed-in user + +`MsalMockClient` takes the same `MsalClientConfig` the real `MsalClient` takes — a client configuration has no notion of who is signed in, so the user is declared separately and signed in on the client as it is built: + +```typescript +import { enableMsalMock } from '@equinor/fusion-framework-module-msal/mock'; + +enableMsalMock(configurator, (builder) => { + builder.setAccount({ name: 'Ada Lovelace', username: 'ada@equinor.com' }); +}); +``` + +Pass `null` when nobody is signed in: + +```typescript +enableMsalMock(configurator, (builder) => { + builder.setAccount(null); +}); +``` + +`setAccount` also takes an ordinary config-builder callback, resolved while the configuration is assembled and handed the same arguments every other builder callback receives: + +```typescript +enableMsalMock(configurator, (builder) => { + builder.setAccount(async ({ hasModule }) => ({ + name: hasModule('app') ? 'App User' : 'Portal User', + })); +}); +``` + +The user is in place **before** `MsalProvider.initialize()` runs, so the provider's own start-up path acts on it. Combined with `setRequiresAuth(true)`, a test observes the real automatic login rather than a state assigned after the fact. + +`setClient` replaces the client, but not the rule: the declared user is signed in on whichever client the module authenticates through, so a mock client supplied that way receives it too. + +| Option | Default | Purpose | +| --- | --- | --- | +| `name` | `Test User` | Display name | +| `username` | `test.user@equinor.com` | UPN / email | +| `userId` | `fusion-mock-user` | Object ID | +| `tenantId` | the client's configured tenant | Tenant | +| `scopes` | `fusion-mock-scope` | Granted when a request specifies none | +| `account` | derived | A preconfigured `AccountInfo` to use outright | +| `signedOut` | `false` | Start without a signed-in user | + +The client tokens are issued for comes from the client configuration (`setClientConfig`), not from the user. + +### Why the user is signed in at construction + +The account is put in the client's cache as the client is built — before the provider exists. That reproduces the production shape of a returning user with a live session: the provider finds an account already there and takes the branch it takes in the browser. + +Assigning the account **after** `MsalProvider.initialize()` — from an `onInitialized` hook, say — looks equivalent but is not. `initialize()` exchanges an auth code, calls `handleRedirect()` and, when `requiresAuth` is set, performs an automatic login. A late assignment silently overwrites all of that, so a test asserting on the sign-in journey would be observing its own assignment rather than the framework. + +## Changing the user between tests + +When a suite shares one framework instance but needs a different user per test, set the active account directly: + +```typescript +beforeEach(() => { + fusion.modules.auth.client.setActiveAccount(account); +}); +``` + +The mock keeps a real account cache, so this behaves the way MSAL does: `getActiveAccount`, `getAllAccounts` and `getAccount(filter)` all agree afterwards. Unlike real MSAL, an account that was never issued by a sign-in is accepted and added to the cache, which is what makes the one-liner above possible. + +Signing out (`logout`, `logoutPopup`, `logoutRedirect`) removes the account from the cache rather than merely deactivating it, as MSAL does. + +## Running inside a host application + +When the module is hoisted onto a host application's provider — an app inside a portal — no client is built. The app authenticates through the host's client, exactly as in production. + +A user declared with `setAccount` is still honoured: it is signed in on the **host's** client, because that is the client the app authenticates through. The alternative would be for `setAccount` to silently do nothing precisely when an app is being tested inside a portal. + +The session is shared, so this changes who the host sees signed in too — as it does in production. If the host does not authenticate through a mock client, `setAccount` throws rather than failing quietly. + +## Testing signed-out behaviour + +Both `null` and `signedOut: true` start without a session. Silent flows then resolve empty so the provider follows its unauthenticated path, while an explicit login still succeeds — which lets a test drive the sign-in journey rather than only its end state. + +They differ in what the login resolves to. `null` forgets the identity, so a login produces the default user: + +```typescript +builder.setAccount(null); +``` + +`signedOut: true` keeps it, so a login produces the user the test named — which is what to reach for when the assertion is about *who* signed in: + +```typescript +builder.setAccount({ name: 'Ada Lovelace', signedOut: true }); +``` + +## Mocking an individual call + +> [!IMPORTANT] +> That is your test runner's job. This module ships **no mocking API**. + +The mock client is a plain class with ordinary methods, so `vi.spyOn`, `bun:test`'s `spyOn` and Node's `t.mock.method` all work on it directly — with their own call assertions, argument matchers and reset semantics, which a framework-specific API would not give you. + +The provider exposes the client it authenticates through, so a spy has a stable target: + +```typescript +vi.spyOn(fusion.modules.auth.client, 'acquireToken').mockResolvedValue(result); + +afterEach(() => vi.restoreAllMocks()); +``` + +## Minting a token directly + +For code that only needs a token — an HTTP interceptor test, say — skip the client: + +```typescript +import { createMockToken } from '@equinor/fusion-framework-module-msal/mock'; + +const token = createMockToken({ oid: 'fusion-mock-user' }); +``` + +## Exports + +| Export | Purpose | +| --- | --- | +| `enableMsalMock(configurator, configure?)` | Register the module with an in-process client | +| `msalMockModule` | The module itself, for manual registration | +| `MsalMockConfigurator` | The real configurator, backed by an in-process client | +| `MsalMockClient(config)` | The in-process client, taking the same `MsalClientConfig` as `MsalClient` | +| `createMsalMockClient(config, user?)` | Convenience alias for `new MsalMockClient(config)` | +| `createMockToken(claims?)` | Mint a deterministic JWT | + +## Related + +- [Module README](../README.md) — production configuration +- [`@equinor/fusion-framework/mock`](../../../framework/docs/testing.md) — mock every framework boundary at once diff --git a/packages/modules/msal/docs/troubleshooting.md b/packages/modules/msal/docs/troubleshooting.md new file mode 100644 index 0000000000..40e979b065 --- /dev/null +++ b/packages/modules/msal/docs/troubleshooting.md @@ -0,0 +1,17 @@ +# Troubleshooting + +## Common Issues + +| Issue | Solution | +|-------|----------| +| **Authentication Loop** | Ensure redirect URIs match your application's routing | +| **Token Acquisition Fails** | Check that required scopes are properly configured | +| **Module Not Found** | Ensure the module is properly configured and framework is initialized | +| **Multiple MSAL Instances** | Remove duplicate configurations from child modules | +| **Redirect Returns Void** | For redirect flows, use `handleRedirect()` after navigation completes | +| **Token Empty/Undefined** | Verify user is authenticated and scopes are correct | + +## Getting Help + +- 📖 [MSAL Cookbook](https://github.com/equinor/fusion-framework/tree/main/cookbooks/app-react-msal) - Complete working examples +- 🐛 [Report Issues](https://github.com/equinor/fusion/issues) - Bug reports and feature requests diff --git a/packages/modules/msal/docs/version-management.md b/packages/modules/msal/docs/version-management.md new file mode 100644 index 0000000000..a090f1dcd0 --- /dev/null +++ b/packages/modules/msal/docs/version-management.md @@ -0,0 +1,67 @@ +# Version Management + +The MSAL module includes built-in version checking to ensure compatibility between different MSAL library versions. + +## Version Resolution + +```typescript +import { resolveVersion, VersionError } from '@equinor/fusion-framework-module-msal/versioning'; + +// Resolve and validate a version +const result = resolveVersion('2.0.0'); +console.log(result.isLatest); // false +console.log(result.satisfiesLatest); // true +console.log(result.enumVersion); // MsalModuleVersion.V2 +``` + +## Version Checking Behavior + +- **Major Version Incompatibility**: Throws `VersionError` if requested major version is greater than latest +- **Minor Version Mismatch**: Logs warning but allows execution +- **Patch Differences**: Ignored for compatibility +- **Invalid Versions**: Throws `VersionError` with descriptive message + +## API Reference + +### `resolveVersion(version: string | SemVer): ResolvedVersion` + +Resolves and validates a version string against the latest available MSAL version. + +**Parameters:** +- `version` - Version string or SemVer object to resolve + +**Returns:** `ResolvedVersion` object containing: +- `wantedVersion: SemVer` - The parsed requested version +- `latestVersion: SemVer` - The latest available version +- `isLatest: boolean` - Whether the version is exactly the latest +- `satisfiesLatest: boolean` - Whether the major version matches latest +- `enumVersion: MsalModuleVersion` - Corresponding enum version + +**Throws:** `VersionError` for invalid or incompatible versions + +### `VersionError` + +Error class for version-related issues with the following types: +- `InvalidVersion` - Requested version is not a valid semver +- `InvalidLatestVersion` - Latest version parsing failed (build issue) +- `MajorIncompatibility` - Major version is greater than latest +- `MinorMismatch` - Minor version differs (warning only) +- `PatchDifference` - Patch version differs (info only) +- `IncompatibleVersion` - General incompatibility + +## Error Handling + +```typescript +import { resolveVersion, VersionError } from '@equinor/fusion-framework-module-msal/versioning'; + +try { + const result = resolveVersion('3.0.0'); // Assuming latest is 2.x +} catch (error) { + if (error instanceof VersionError) { + console.error('Version error:', error.message); + console.error('Requested:', error.requestedVersion); + console.error('Latest:', error.latestVersion); + console.error('Type:', error.type); + } +} +``` diff --git a/packages/modules/msal/package.json b/packages/modules/msal/package.json index ff645287c9..fbf15e6e76 100644 --- a/packages/modules/msal/package.json +++ b/packages/modules/msal/package.json @@ -16,6 +16,10 @@ "./v4": { "import": "./dist/esm/v4/index.js", "types": "./dist/types/v4/index.d.ts" + }, + "./mock": { + "import": "./dist/esm/mock/index.js", + "types": "./dist/types/mock/index.d.ts" } }, "typesVersions": { @@ -28,6 +32,9 @@ ], "v4": [ "dist/types/v4/index.d.ts" + ], + "mock": [ + "dist/types/mock/index.d.ts" ] } }, diff --git a/packages/modules/msal/src/MsalConfigurator.ts b/packages/modules/msal/src/MsalConfigurator.ts index b7d8449ede..487157e3f7 100644 --- a/packages/modules/msal/src/MsalConfigurator.ts +++ b/packages/modules/msal/src/MsalConfigurator.ts @@ -1,68 +1,23 @@ -import z from 'zod'; -import { BaseConfigBuilder } from '@equinor/fusion-framework-module'; -import semver from 'semver'; -import type { IMsalProvider } from './MsalProvider.interface'; import { - TelemetryLevel, - type ITelemetryProvider, -} from '@equinor/fusion-framework-module-telemetry'; + BaseConfigBuilder, + type ConfigBuilderCallbackArgs, +} from '@equinor/fusion-framework-module'; +import { TelemetryLevel } from '@equinor/fusion-framework-module-telemetry'; +import { CacheLookupPolicy, LogLevel } from '@azure/msal-browser'; + +import type { ITelemetryProvider } from '@equinor/fusion-framework-module-telemetry'; +import type { IMsalProvider } from './MsalProvider.interface'; import { MsalClient, type MsalClientConfig, type IMsalClient } from './MsalClient'; import { createClientLogCallback } from './create-client-log-callback'; -import { CacheLookupPolicy, LogLevel } from '@azure/msal-browser'; import { version } from './version'; +import { MsalConfigSchema, type MsalConfig } from './msal-config-schema'; -/** - * Zod schema for telemetry configuration validation. - * - * @internal - */ -const TelemetryConfigSchema = z.object({ - provider: z.custom().optional(), - metadata: z.record(z.string(), z.unknown()).optional().default({ - module: 'msal', - version, - }), - scope: z.array(z.string()).optional().default(['framework', 'authentication']), -}); - -/** - * Telemetry configuration for MSAL module. - * - * This configuration controls how authentication events are tracked and logged - * through the framework's telemetry system. - */ -export type TelemetryConfig = z.infer; - -/** - * Zod schema for MSAL module configuration validation. - * - * @internal - */ -const MsalConfigSchema = z.object({ - client: z.custom().optional(), - provider: z.custom().optional(), - requiresAuth: z.boolean().optional(), - redirectUri: z.string().optional(), - loginHint: z.string().optional(), - authCode: z.string().optional(), - cacheLookupPolicy: z - .custom( - (val) => - typeof val === 'number' && - Object.values(CacheLookupPolicy).includes(val as CacheLookupPolicy), - ) - .optional(), - version: z.string().transform((x: string) => String(semver.coerce(x))), - telemetry: TelemetryConfigSchema, -}); - -/** - * Complete configuration object for MSAL authentication module. - * - * This type represents the full configuration including client setup, authentication - * requirements, telemetry, and version information. - */ -export type MsalConfig = z.infer; +export { + MsalConfigSchema, + type MsalConfig, + type MsalConfigExtension, +} from './msal-config-schema'; +export { TelemetryConfigSchema, type TelemetryConfig } from './telemetry-config-schema'; /** * Configuration builder for MSAL v4 authentication module. @@ -79,6 +34,7 @@ export type MsalConfig = z.infer; */ export class MsalConfigurator extends BaseConfigBuilder { #msalConfig?: MsalClientConfig; + #client?: IMsalClient; /** * The MSAL module version being configured. @@ -107,6 +63,9 @@ export class MsalConfigurator extends BaseConfigBuilder { return telemetry; } }); + // Always resolve the configured client instance through the builder. + // This keeps the client getter live and avoids re-registering the same config key. + this._set('client', async () => this.#client); // Default cache lookup policy to AccessTokenAndRefreshToken to avoid iframe fallback delays this._set('cacheLookupPolicy', async () => CacheLookupPolicy.AccessTokenAndRefreshToken); } @@ -136,6 +95,23 @@ export class MsalConfigurator extends BaseConfigBuilder { return this; } + /** + * Returns the client configuration declared through + * {@link MsalConfigurator.setClientConfig | setClientConfig}, if any. + * + * @remarks + * This is the configuration as declared, not the resolved one a client is + * built from — see + * {@link MsalConfigurator._createClientConfig | _createClientConfig} for that. + * Reading it is how a subclass can tell "nothing was declared" apart from + * "declared, and here it is", without re-deriving that from a resolved value. + * + * @returns The declared client configuration, or `undefined` when none was declared. + */ + public getClientConfig(): MsalClientConfig | undefined { + return this.#msalConfig; + } + /** * Sets the cache lookup policy used for every silent token acquisition. * @@ -274,10 +250,23 @@ export class MsalConfigurator extends BaseConfigBuilder { * ``` */ setClient(client: IMsalClient): this { - this._set('client', async () => client); + this.#client = client; return this; } + /** + * Returns the currently configured MSAL client, if one has been set. + * + * @remarks + * This is useful in tests when a mock client has been provided and the test + * wants to adjust its state after it has been assigned to the configurator. + * + * @returns The configured client, or `undefined` when none has been set. + */ + public getClient(): IMsalClient | undefined { + return this.#client; + } + /** * Sets telemetry provider for MSAL authentication events. * @@ -328,72 +317,170 @@ export class MsalConfigurator extends BaseConfigBuilder { /** * Processes and validates the configuration. * - * @param config - Raw configuration object + * @param rawConfig - Raw configuration object + * @param init - The builder arguments, carrying the host reference when hoisted * @returns Processed and validated configuration */ - async _processConfig(rawConfig: MsalConfig): Promise { + async _processConfig( + rawConfig: MsalConfig, + init?: ConfigBuilderCallbackArgs, + ): Promise { // Validate and coerce configuration using Zod schema const config = await MsalConfigSchema.parseAsync(rawConfig); - // Auto-create client if config provided but no client instance + // Auto-create client if no client instance was supplied // This allows users to provide configuration without manually instantiating the client - if (!config.client && this.#msalConfig) { - const clientConfig = this.#msalConfig; + // A hoisted module authenticates through the host's provider, so any client built here + // would be discarded — gate it here rather than in `_createClient`, so a substituted + // client (see `MsalMockConfigurator`) cannot shadow the host's signed-in user + if (!config.client && !this._isHoisted(init)) { + config.client = await this._createClient(config, init); + } - config.telemetry.provider?.trackEvent({ - name: 'module-msal.configurator._processConfig.creating-client', - level: TelemetryLevel.Debug, - scope: config.telemetry.scope, - metadata: { ...config.telemetry.metadata, clientConfig }, - }); + return config; + } - // Auto-generate authority URL from tenant ID if not explicitly provided - // This simplifies configuration for most common cases - if (!clientConfig.auth.authority && clientConfig.auth.tenantId) { - clientConfig.auth.authority = `https://login.microsoftonline.com/${clientConfig.auth.tenantId}`; - } + /** + * Creates the client to authenticate through, when none was supplied. + * + * @remarks + * Called by {@link MsalConfigurator._processConfig | _processConfig} only when + * no client was set, so a client supplied through + * {@link MsalConfigurator.setClient | setClient} always wins. It is likewise + * not called when the module is hoisted onto a host application's provider — + * see {@link MsalConfigurator._isHoisted | _isHoisted}. + * + * This is the seam for authenticating through something other than Entra ID. + * Overriding it replaces only the client, leaving the builder, the schema + * validation and `MsalProvider` untouched — which is how + * `MsalMockConfigurator` substitutes an in-process client for tests. + * + * An override normally builds from + * {@link MsalConfigurator._createClientConfig | _createClientConfig}, so it + * receives the same fully-resolved {@link MsalClientConfig} the real client is + * built from rather than re-deriving it. + * + * Returning `undefined` is legitimate and means "there is nothing to build a + * client from", which leaves the module without one. + * + * @param config - The validated configuration the client is built from. + * @param init - The builder arguments, carrying the host reference when hoisted. + * @returns The client, or `undefined` when there is nothing to build one from. + */ + protected async _createClient( + config: MsalConfig, + _init?: ConfigBuilderCallbackArgs, + ): Promise { + const clientConfig = this._createClientConfig(config); + // A client can be omitted for a hoisted module or an intentionally incomplete setup. + if (!clientConfig) { + return undefined; + } - // Set default cache location to localStorage for browser environments - // MSAL supports sessionStorage as well, but localStorage is the standard for persistent auth - if (!clientConfig.cache) { - clientConfig.cache = { cacheLocation: 'localStorage' }; - } + // Instantiate MSAL client with fully configured options + return new MsalClient(clientConfig); + } - // Integrate framework telemetry with MSAL logging system - // This allows MSAL events to flow through the framework's telemetry pipeline - if (!clientConfig.system?.loggerOptions && config.telemetry?.provider) { - const { provider, metadata, scope } = config.telemetry; - - provider.trackEvent({ - name: 'module-msal.configurator._processConfig.client-telemetry-connected', - level: TelemetryLevel.Debug, - scope, - metadata, - }); - - clientConfig.system = { - ...clientConfig.system, - loggerOptions: { - // Only log PII in development to protect user privacy in production - piiLoggingEnabled: process.env.NODE_ENV === 'development', - // Bridge MSAL log events to framework telemetry system - loggerCallback: createClientLogCallback(provider, metadata, [...scope, '3rd-party']), - // Use Warning level by default - captures errors and warnings without being verbose - logLevel: LogLevel.Warning, - // Preserve any user-provided logger options (allows customization) - ...clientConfig.system?.loggerOptions, - }, - }; - } - // Apply silent cache lookup policy if configured - if (config.cacheLookupPolicy !== undefined) { - clientConfig.cacheLookupPolicy = config.cacheLookupPolicy; - } + /** + * Whether this module is hoisted onto a host application's authentication. + * + * @remarks + * When an application runs inside a host — a portal loading an app, or an app + * loading a widget — the module initializer returns a proxy of the host's + * provider instead of building its own (see the host-provider branch of the + * module initializer). A client built during configuration would therefore be + * constructed and immediately discarded. + * + * Detecting this during configuration lets the configurator skip building a + * client entirely, which matters most for substituted clients: a mock client + * built here would otherwise silently shadow the host's real signed-in user. + * + * @param init - The builder arguments, carrying the host reference when hoisted. + * @returns `true` when a host provider will be used instead of a locally built client. + */ + protected _isHoisted(init?: ConfigBuilderCallbackArgs): boolean { + return !!(init?.ref as { auth?: IMsalProvider } | undefined)?.auth; + } - // Instantiate MSAL client with fully configured options - config.client = new MsalClient(clientConfig); + /** + * Resolves the full MSAL client configuration to build a client from. + * + * @remarks + * Applies the defaults a client is expected to be built with — authority + * derived from the tenant, cache location, telemetry-backed logging and the + * configured cache lookup policy. + * + * Kept separate from {@link MsalConfigurator._createClient | _createClient} so + * that substituting the client does not also mean re-implementing this + * resolution. `MsalMockConfigurator` relies on it to hand its mock client the + * very same configuration the real client would have received. + * + * @param config - The validated configuration. + * @returns The client configuration, or `undefined` when none was declared. + */ + protected _createClientConfig(config: MsalConfig): MsalClientConfig | undefined { + const declared = this.#msalConfig; + // Do not construct a client when configuration has not supplied client settings. + if (!declared) { + return undefined; } - return config; + config.telemetry.provider?.trackEvent({ + name: 'module-msal.configurator._processConfig.creating-client', + level: TelemetryLevel.Debug, + scope: config.telemetry.scope, + metadata: { ...config.telemetry.metadata, clientConfig: declared }, + }); + + // Copied rather than enriched in place, so the object a caller passed to + // `setClientConfig` is never rewritten behind its back — a caller may well + // be reusing or asserting on it + const clientConfig: MsalClientConfig = { + ...declared, + auth: { ...declared.auth }, + // Default to localStorage: MSAL supports sessionStorage too, but + // localStorage is the standard for persistent auth in browsers + cache: declared.cache ?? { cacheLocation: 'localStorage' }, + }; + + // Auto-generate authority URL from tenant ID if not explicitly provided + // This simplifies configuration for most common cases + if (!clientConfig.auth.authority && clientConfig.auth.tenantId) { + clientConfig.auth.authority = `https://login.microsoftonline.com/${clientConfig.auth.tenantId}`; + } + + // Integrate framework telemetry with MSAL logging system + // This allows MSAL events to flow through the framework's telemetry pipeline + if (!clientConfig.system?.loggerOptions && config.telemetry?.provider) { + const { provider, metadata, scope } = config.telemetry; + + provider.trackEvent({ + name: 'module-msal.configurator._processConfig.client-telemetry-connected', + level: TelemetryLevel.Debug, + scope, + metadata, + }); + + clientConfig.system = { + ...clientConfig.system, + loggerOptions: { + // Only log PII in development to protect user privacy in production + piiLoggingEnabled: process.env.NODE_ENV === 'development', + // Bridge MSAL log events to framework telemetry system + loggerCallback: createClientLogCallback(provider, metadata, [...scope, '3rd-party']), + // Use Warning level by default - captures errors and warnings without being verbose + logLevel: LogLevel.Warning, + // Preserve any user-provided logger options (allows customization) + ...clientConfig.system?.loggerOptions, + }, + }; + } + + // Apply silent cache lookup policy if configured + if (config.cacheLookupPolicy !== undefined) { + clientConfig.cacheLookupPolicy = config.cacheLookupPolicy; + } + + return clientConfig; } } diff --git a/packages/modules/msal/src/__tests__/MsalConfigurator.test.ts b/packages/modules/msal/src/__tests__/MsalConfigurator.test.ts index 6778805469..a7040b53c4 100644 --- a/packages/modules/msal/src/__tests__/MsalConfigurator.test.ts +++ b/packages/modules/msal/src/__tests__/MsalConfigurator.test.ts @@ -20,6 +20,23 @@ const createInitialConfig = (): Pick => ({ }); describe('MsalConfigurator', () => { + it('enriches a copy, leaving the declared client configuration untouched', async () => { + // A caller may reuse or assert on the object it passed, and the defaults + // applied here are derived — rewriting it behind their back is not ours to do + const declared = { auth: { clientId: 'my-app', tenantId: 'my-tenant' } }; + const configurator = new MsalConfigurator(); + + configurator.setClientConfig(declared); + + const config = await configurator.createConfigAsync( + createConfigCallbackArgs(), + createInitialConfig(), + ); + + expect(declared).toEqual({ auth: { clientId: 'my-app', tenantId: 'my-tenant' } }); + expect(config.client?.tenantId).toBe('my-tenant'); + }); + it('setAuthCode should normalize surrounding whitespace', async () => { const configurator = new MsalConfigurator(); @@ -74,6 +91,95 @@ describe('MsalConfigurator', () => { expect(config.client).toBeUndefined(); }); + describe('_createClient', () => { + it('builds from the same resolved client config the real client would get', async () => { + // The mock relies on this: substituting the client must not also mean + // re-implementing authority, cache and telemetry resolution + const received: unknown[] = []; + class CustomConfigurator extends MsalConfigurator { + protected override async _createClient(config: MsalConfig): Promise { + received.push(this._createClientConfig(config)); + return createClient(); + } + } + + const configurator = new CustomConfigurator(); + configurator.setClientConfig({ auth: { clientId: 'client-id', tenantId: 'tenant-id' } }); + + await configurator.createConfigAsync(createConfigCallbackArgs(), createInitialConfig()); + + expect(received).toEqual([ + expect.objectContaining({ + auth: expect.objectContaining({ + clientId: 'client-id', + // derived by the configurator, not by the caller + authority: 'https://login.microsoftonline.com/tenant-id', + }), + cache: { cacheLocation: 'localStorage' }, + }), + ]); + }); + + it('supplies the client when none was set', async () => { + const client = createClient(); + class CustomConfigurator extends MsalConfigurator { + protected override async _createClient(): Promise { + return client; + } + } + + const config = await new CustomConfigurator().createConfigAsync( + createConfigCallbackArgs(), + createInitialConfig(), + ); + + expect(config.client).toBe(client); + }); + + it('is not consulted when a client was set, so setClient always wins', async () => { + const own = createClient(); + const createOther = vi.fn().mockResolvedValue(createClient()); + class CustomConfigurator extends MsalConfigurator { + protected override _createClient(): Promise { + return createOther(); + } + } + + const configurator = new CustomConfigurator(); + configurator.setClient(own); + + const config = await configurator.createConfigAsync( + createConfigCallbackArgs(), + createInitialConfig(), + ); + + expect(config.client).toBe(own); + expect(createOther).not.toHaveBeenCalled(); + }); + + it('is not consulted when hoisted, so a host provider is never shadowed', async () => { + // A hoisted module authenticates through the host's provider, so anything + // built here would be discarded — or worse, shadow the host's user + const createOther = vi.fn().mockResolvedValue(createClient()); + class CustomConfigurator extends MsalConfigurator { + protected override _createClient(): Promise { + return createOther(); + } + } + + const configurator = new CustomConfigurator(); + configurator.setClientConfig({ auth: { clientId: 'client-id', tenantId: 'tenant-id' } }); + + const config = await configurator.createConfigAsync( + { ...createConfigCallbackArgs(), ref: { auth: {} } }, + createInitialConfig(), + ); + + expect(config.client).toBeUndefined(); + expect(createOther).not.toHaveBeenCalled(); + }); + }); + describe('cacheLookupPolicy', () => { it('defaults to CacheLookupPolicy.AccessTokenAndRefreshToken', async () => { const configurator = new MsalConfigurator(); diff --git a/packages/modules/msal/src/__tests__/mock/msal-mock.test.ts b/packages/modules/msal/src/__tests__/mock/msal-mock.test.ts new file mode 100644 index 0000000000..d80394dec8 --- /dev/null +++ b/packages/modules/msal/src/__tests__/mock/msal-mock.test.ts @@ -0,0 +1,544 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + ModulesConfigurator, + type ConfigBuilderCallbackArgs, +} from '@equinor/fusion-framework-module'; +import telemetryModule from '@equinor/fusion-framework-module-telemetry'; +import type { AccountInfo, AuthenticationResult } from '@azure/msal-browser'; + +import { MsalConfigurator, type MsalConfig } from '../../MsalConfigurator'; +import { MsalProvider } from '../../MsalProvider'; +import type { IMsalProvider } from '../../MsalProvider.interface'; +import { enableMSAL, module as realModule } from '../../module'; +import { + MsalMockClient, + createMsalMockClient, + enableMsalMock, + msalMockModule, + MsalMockConfigurator, + type MsalMockUser, +} from '../../mock'; + +/** + * Initializes the mock module through the real module system. + * + * @remarks + * Deliberately avoids hand-building initialization arguments. Faking the module + * system is the very cost this work removes, and a hand-rolled `init` would test + * the fake rather than the module. The telemetry module is registered because the + * MSAL schema requires a telemetry provider to be present. + * + * @param options - The user the mock client represents. + * @returns The provider the module produced. + */ +const initializeMockWith = async ( + configure?: (builder: MsalMockConfigurator) => void, +): Promise => { + const configurator = new ModulesConfigurator([telemetryModule]); + enableMsalMock(configurator, configure); + const instances = await configurator.initialize(); + return (instances as unknown as { auth: MsalProvider }).auth; +}; + +/** + * Initializes the mock module for a given account. + * + * @param options - The user the mock client represents. + * @returns The provider the module produced. + */ +const initializeMock = (user?: MsalMockUser): Promise => + initializeMockWith(user && ((builder) => builder.setAccount(user))); + +/** The client configuration a test would declare, identical for real and mock. */ +const clientConfig = (clientId = 'fusion-mock-client', tenantId = 'fusion-mock-tenant') => ({ + auth: { clientId, tenantId }, +}); + +describe('msalMockModule', () => { + it('changes nothing but the configurator', () => { + expect(msalMockModule.name).toBe(realModule.name); + expect(msalMockModule.version).toBe(realModule.version); + // The production initializer, untouched — the mock has no lifecycle of its own + expect(msalMockModule.initialize).toBe(realModule.initialize); + }); + + it('builds a real MsalConfigurator, so the whole builder stays available', () => { + const configurator = msalMockModule.configure?.(); + + expect(configurator).toBeInstanceOf(MsalConfigurator); + expect(configurator).toBeInstanceOf(MsalMockConfigurator); + }); +}); + +describe('enableMsalMock', () => { + it('produces the real MsalProvider, not a stand-in', async () => { + const provider = await initializeMock(); + + // The point of substituting the client rather than the provider: everything + // above the network boundary is production code + expect(provider).toBeInstanceOf(MsalProvider); + }); + + it('signs in a user without any client configuration', async () => { + const provider = await initializeMock({ name: 'Ada Lovelace' }); + + expect(provider.account?.name).toBe('Ada Lovelace'); + }); + + 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([telemetryModule, realModule]); + enableMsalMock(configurator); + + const instances = await configurator.initialize(); + + expect((instances as unknown as { auth: MsalProvider }).auth.account?.name).toBe('Test User'); + }); + + it('declares the account without constructing a client', () => { + const configurator = new MsalMockConfigurator(); + + configurator.setAccount({ username: 'ada@equinor.com' }); + + // Nothing is built until the module assembles its config + expect(configurator.getClient()).toBeUndefined(); + expect(configurator.getClientConfig()).toBeUndefined(); + }); + + it('resolves an account callback with the ordinary builder arguments', async () => { + // An ordinary config-builder callback, so a test can reach the modules in + // scope rather than being handed a bespoke signature + const provider = await initializeMockWith((builder) => + builder.setAccount(async ({ hasModule }) => ({ + name: hasModule('telemetry') ? 'Ada Lovelace' : 'Nobody', + })), + ); + + expect(provider.account?.name).toBe('Ada Lovelace'); + }); + + it('signs the user in before the provider initializes', async () => { + // The provider's own start-up path must see the declared state, or a test + // could not observe what the framework does with it + const provider = await initializeMockWith((builder) => { + builder.setAccount({ signedOut: true }); + builder.setRequiresAuth(true); + }); + + // `requiresAuth` made the real provider log in during initialize + expect(provider.account?.username).toBe('test.user@equinor.com'); + }); + + it('signs the declared user in on a client that was set explicitly', async () => { + // The rule is uniform: the user goes on whichever client the module + // authenticates through, wherever that client came from + const own = new MsalMockClient(clientConfig()); + own.setUser({ name: 'Grace Hopper' }); + + const provider = await initializeMockWith((builder) => { + builder.setClient(own); + builder.setAccount({ name: 'Ada Lovelace' }); + }); + + expect(provider.client).toBe(own); + expect(provider.account?.name).toBe('Ada Lovelace'); + }); + + it('leaves a client that was set explicitly alone when no user is declared', async () => { + const own = new MsalMockClient(clientConfig()); + own.setUser({ name: 'Grace Hopper' }); + + const provider = await initializeMockWith((builder) => builder.setClient(own)); + + expect(provider.account?.name).toBe('Grace Hopper'); + }); + + it('refuses to declare a user on a client that cannot represent one', async () => { + // Failing quietly is the whole failure mode this exists to prevent + const configurator = new ModulesConfigurator([telemetryModule]); + enableMsalMock(configurator, (builder) => { + builder.setClient({} as unknown as MsalMockClient); + builder.setAccount({ name: 'Ada Lovelace' }); + }); + + await expect(configurator.initialize()).rejects.toThrow( + /does not authenticate through a mock client/, + ); + }); + + it('carries the user on the configuration, resolved by the builder', async () => { + // The user travels the ordinary pipeline as `mock.account` rather than + // living on the builder, so any code with the raw configuration can read it + const configurator = new MsalMockConfigurator(); + configurator.setAccount(async () => ({ name: 'Ada Lovelace' })); + + let rawConfig: MsalConfig | undefined; + vi.spyOn(configurator, '_processConfig').mockImplementation(async (config) => { + rawConfig = config; + return config as MsalConfig; + }); + + await configurator.createConfigAsync({ + requireInstance: async () => undefined, + hasModule: () => false, + config: {}, + } as unknown as ConfigBuilderCallbackArgs); + + expect(rawConfig?.mock?.account).toEqual({ name: 'Ada Lovelace' }); + }); + + it('applies the account as it ends up, not as it started', async () => { + const provider = await initializeMockWith((builder) => { + builder.setAccount({ name: 'Ada Lovelace' }); + builder.setAccount({ name: 'Grace Hopper' }); + }); + + expect(provider.client).toBeInstanceOf(MsalMockClient); + expect(provider.account?.name).toBe('Grace Hopper'); + }); + + it('builds its client from setClientConfig, exactly as the real module does', async () => { + const provider = await initializeMockWith((builder) => + builder.setClientConfig(clientConfig('my-app', 'my-tenant')), + ); + + expect(provider.client).toBeInstanceOf(MsalMockClient); + expect(provider.client.clientId).toBe('my-app'); + expect(provider.client.tenantId).toBe('my-tenant'); + }); + + it('exposes the client built for a signed-out account', async () => { + const provider = await initializeMockWith((builder) => builder.setAccount({ signedOut: true })); + const client = provider.client; + + expect(client.hasValidClaims).toBe(false); + + client.setActiveAccount({ + homeAccountId: 'id.tenant', + localAccountId: 'id', + environment: 'login.microsoftonline.com', + tenantId: 'tenant', + username: 'user@equinor.com', + name: 'User', + }); + + expect(client.hasValidClaims).toBe(true); + }); + + it('starts with no account when signed out', async () => { + const provider = await initializeMock({ signedOut: true }); + + expect(provider.account).toBeNull(); + }); + + it('runs the real sign-in flow through the provider', async () => { + const provider = await initializeMock({ signedOut: true }); + expect(provider.account).toBeNull(); + + await provider.login({ request: { scopes: ['User.Read'] } }); + + expect(provider.account?.username).toBe('test.user@equinor.com'); + }); + + it('runs the real sign-out flow through the provider', async () => { + const provider = await initializeMock(); + expect(provider.account).not.toBeNull(); + + await provider.logout(); + + expect(provider.account).toBeNull(); + }); + + it('lets the real provider resolve default scopes', async () => { + const provider = await initializeMockWith((builder) => + builder.setClientConfig(clientConfig('my-app')), + ); + + const token = await provider.acquireAccessToken(); + const claims = JSON.parse(atob(token?.split('.')[1] ?? '')); + + // MsalProvider — not the mock — turns "no scopes requested" into `${clientId}/.default` + expect(claims.scp).toBe('my-app/.default'); + }); +}); + +describe('enableMsalMock when hoisted onto a host', () => { + /** + * Initializes the mock module inside a host application. + * + * @remarks + * `ModulesConfigurator.initialize` forwards its argument as the module `ref`, + * which is exactly how a portal hands its modules to an app it loads. Going + * through the real module system keeps the proxy-provider path under test. + * + * @param configure - Configuration applied to the hosted (inner) module. + * @returns The provider the hosted module produced. + */ + const initializeHosted = async ( + configure?: (builder: MsalMockConfigurator) => void, + ): Promise<{ host: MsalProvider; hosted: IMsalProvider }> => { + const host = await initializeMockWith((builder) => builder.setAccount({ name: 'Host User' })); + + const configurator = new ModulesConfigurator([telemetryModule]); + enableMsalMock(configurator, configure); + const instances = await configurator.initialize({ auth: host }); + + return { host, hosted: (instances as unknown as { auth: IMsalProvider }).auth }; + }; + + it('authenticates through the host instead of building a client of its own', async () => { + const { host, hosted } = await initializeHosted(); + + expect(hosted).not.toBe(host); + expect(hosted.account?.name).toBe('Host User'); + }); + + it('signs the declared user in on the host client, since none is built here', async () => { + // The client belongs to the host, built in a scope this builder never sees. + // Reaching it is the only way a declaration made here can take effect at + // all — otherwise `setAccount` silently does nothing exactly when an + // application is being tested inside a portal + const { host, hosted } = await initializeHosted((builder) => + builder.setAccount({ name: 'Ada Lovelace' }), + ); + + expect(hosted.account?.name).toBe('Ada Lovelace'); + // The session is shared, as it is in production + expect(host.account?.name).toBe('Ada Lovelace'); + }); + + it('leaves the host user alone when the app declares none', async () => { + const { host, hosted } = await initializeHosted(); + + expect(hosted.account?.name).toBe('Host User'); + expect(host.account?.name).toBe('Host User'); + }); + + it('refuses to declare a user the host cannot honour', async () => { + // Failing quietly here is the bug this path exists to prevent + const host = await initializeMockWith(); + const realHost = { ...host, client: {} } as unknown as IMsalProvider; + + const configurator = new ModulesConfigurator([telemetryModule]); + enableMsalMock(configurator, (builder) => builder.setAccount({ name: 'Ada Lovelace' })); + + await expect(configurator.initialize({ auth: realHost })).rejects.toThrow( + /does not authenticate through a mock client/, + ); + }); + + it('declares no client configuration at all, so nothing stands in for the host', async () => { + // The stand-in configuration exists only to build a client from; a hoisted + // module builds none, so it must not look configured either + let hosted: MsalMockConfigurator | undefined; + const host = await initializeMockWith((builder) => builder.setAccount({ name: 'Host User' })); + + const configurator = new ModulesConfigurator([telemetryModule]); + enableMsalMock(configurator, (builder) => { + hosted = builder; + }); + await configurator.initialize({ auth: host }); + + expect(hosted?.getClientConfig()).toBeUndefined(); + expect(hosted?.getClient()).toBeUndefined(); + }); +}); + +describe('createMsalMockClient with the real module', () => { + it('needs no mock module — the client is enough', async () => { + const configurator = new ModulesConfigurator([telemetryModule]); + enableMSAL(configurator, (builder) => { + builder.setClient(createMsalMockClient(clientConfig(), { name: 'Ada Lovelace' })); + }); + + const instances = await configurator.initialize(); + + expect((instances as unknown as { auth: MsalProvider }).auth.account?.name).toBe( + 'Ada Lovelace', + ); + }); +}); + +describe('MsalMockClient', () => { + it('takes the same argument as the real client', () => { + const client = new MsalMockClient(clientConfig('my-app', 'my-tenant')); + + expect(client.clientId).toBe('my-app'); + expect(client.tenantId).toBe('my-tenant'); + }); + + it('reads the tenant out of the authority when none was given', () => { + // A production config often carries only `authority`, and the tenant still + // has to reach the tokens the client mints + const client = new MsalMockClient({ + auth: { + clientId: 'my-app', + authority: 'https://login.microsoftonline.com/authority-tenant', + }, + }); + + expect(client.tenantId).toBe('authority-tenant'); + }); + + it('is not mistaken for a promise', () => { + const client = new MsalMockClient(clientConfig()); + + expect((client as unknown as { then?: unknown }).then).toBeUndefined(); + }); + + it('fails silent sign-in without an account, as MSAL does', async () => { + const client = new MsalMockClient(clientConfig()); + client.setUser({ signedOut: true }); + + await expect(client.ssoSilent({ scopes: ['X'] })).rejects.toThrow(/no cached account/); + }); + + it('issues identical tokens across instances', async () => { + const first = new MsalMockClient(clientConfig()); + const second = new MsalMockClient(clientConfig()); + + const a = await first.acquireToken({ request: { scopes: ['X'] } }); + const b = await second.acquireToken({ request: { scopes: ['X'] } }); + + expect(a?.accessToken).toBe(b?.accessToken); + }); + + it('lets a test runner spy on a single method', async () => { + const client = new MsalMockClient(clientConfig()); + + vi.spyOn(client, 'acquireToken').mockResolvedValue({ + account: client.getActiveAccount(), + accessToken: 'mock-token', + idToken: 'mock-token', + scopes: ['mock'], + tokenType: 'Bearer', + expiresOn: new Date('2033-11-14T22:13:20.000Z'), + authority: 'https://login.microsoftonline.com/mock', + uniqueId: 'mock-user', + tenantId: 'mock-tenant', + fromCache: false, + correlationId: 'fusion-mock-correlation', + } as unknown as AuthenticationResult); + + const result = await client.acquireToken({ request: { scopes: ['X'] } }); + + expect(result?.accessToken).toBe('mock-token'); + }); +}); + +describe('setAccount(null)', () => { + it('starts with nobody signed in', async () => { + const provider = await initializeMockWith((builder) => builder.setAccount(null)); + + expect(provider.account).toBeNull(); + expect(provider.client.hasValidClaims).toBe(false); + }); + + it('is a declaration, not the absence of one', async () => { + // Saying "nobody" has to beat the default signed-in user, so it cannot be + // treated the same as saying nothing at all + const provider = await initializeMockWith((builder) => { + builder.setAccount({ name: 'Ada Lovelace' }); + builder.setAccount(null); + }); + + expect(provider.account).toBeNull(); + }); + + it('forgets the identity, unlike signedOut which keeps it', async () => { + const forgotten = await initializeMockWith((builder) => { + builder.setAccount({ name: 'Ada Lovelace' }); + builder.setAccount(null); + }); + const remembered = await initializeMockWith((builder) => + builder.setAccount({ name: 'Ada Lovelace', signedOut: true }), + ); + + await forgotten.client.login({ request: { scopes: [] } }); + await remembered.client.login({ request: { scopes: [] } }); + + expect(forgotten.account?.name).toBe('Test User'); + expect(remembered.account?.name).toBe('Ada Lovelace'); + }); + + it('resolves null from a callback too', async () => { + const provider = await initializeMockWith((builder) => builder.setAccount(async () => null)); + + expect(provider.account).toBeNull(); + }); +}); + +describe('MsalMockClient account cache', () => { + it('swaps the signed-in user on a live framework, as the docs describe', async () => { + const provider = await initializeMock(); + const account = { + ...(provider.account as AccountInfo), + homeAccountId: 'ada', + name: 'Ada Lovelace', + }; + + provider.client.setActiveAccount(account); + + expect(provider.account?.name).toBe('Ada Lovelace'); + }); + + it('holds the signed-in user in the account cache', () => { + const client = new MsalMockClient(clientConfig()); + + expect(client.getAllAccounts()).toEqual([client.getActiveAccount()]); + }); + + it('empties the cache when the user signs out', async () => { + const client = new MsalMockClient(clientConfig()); + + await client.logout(); + + expect(client.getActiveAccount()).toBeNull(); + expect(client.getAllAccounts()).toEqual([]); + }); + + it('matches a cached account on the filter it is given', () => { + const client = new MsalMockClient(clientConfig()); + const account = client.getActiveAccount(); + + expect(client.getAccount({ username: account?.username })).toBe(account); + expect(client.getAccount({ homeAccountId: account?.homeAccountId })).toBe(account); + expect(client.getAccount({ username: 'someone.else@equinor.com' })).toBeNull(); + }); + + it('caches an account a test makes active', () => { + // Tests swap the user per run rather than reconstructing the framework, so + // an account MSAL never issued still has to end up in the cache + const client = new MsalMockClient(clientConfig()); + const account = { + ...(client.getActiveAccount() as AccountInfo), + homeAccountId: 'grace.hopper', + username: 'grace.hopper@equinor.com', + name: 'Grace Hopper', + }; + + client.setActiveAccount(account); + + expect(client.getActiveAccount()?.name).toBe('Grace Hopper'); + expect(client.getAccount({ username: 'grace.hopper@equinor.com' })).toBe(account); + }); + + it('replaces the session when a new user is declared', () => { + const client = new MsalMockClient(clientConfig()); + + client.setUser({ name: 'Ada Lovelace', userId: 'ada' }); + + expect(client.getAllAccounts()).toHaveLength(1); + expect(client.getActiveAccount()?.name).toBe('Ada Lovelace'); + }); + + it('leaves no account behind when signed out', () => { + const client = new MsalMockClient(clientConfig()); + + client.setUser({ signedOut: true }); + + expect(client.getAllAccounts()).toEqual([]); + expect(client.hasValidClaims).toBe(false); + }); +}); diff --git a/packages/modules/msal/src/index.ts b/packages/modules/msal/src/index.ts index 493faff1a7..590c246313 100644 --- a/packages/modules/msal/src/index.ts +++ b/packages/modules/msal/src/index.ts @@ -37,6 +37,12 @@ export type { IMsalProvider } from './MsalProvider.interface'; export type { IMsalClient } from './MsalClient.interface'; export { MsalClient, type MsalClientConfig } from './MsalClient'; +/** + * Required to implement {@link IMsalProvider}, whose `msalVersion` member is + * typed as this enum. + */ +export { MsalModuleVersion } from './static'; + export type { AccountInfo, AuthenticationResult } from './types'; export { default } from './module'; diff --git a/packages/modules/msal/src/mock/MsalMockClient.ts b/packages/modules/msal/src/mock/MsalMockClient.ts new file mode 100644 index 0000000000..ddb20a69d2 --- /dev/null +++ b/packages/modules/msal/src/mock/MsalMockClient.ts @@ -0,0 +1,599 @@ +import type { + AccountInfo, + AuthenticationResult, + AuthorizationCodeRequest, + PopupRequest, + RedirectRequest, + SilentRequest, + SsoSilentRequest, + EndSessionRequest, + EndSessionPopupRequest, + InitializeApplicationRequest, + WrapperSKU, + INavigationClient, + BrowserConfiguration, + Logger, + PerformanceCallbackFunction, + EventCallbackFunction, + EventType, + ClearCacheRequest, +} from '@azure/msal-browser'; + +import type { + AcquireTokenOptions, + AcquireTokenResult, + IMsalClient, + LoginOptions, + LoginResult, +} from '../MsalClient.interface'; +import type { MsalClientConfig, MsalClient } from '../MsalClient'; + +import { createMockToken } from './create-mock-token'; +/** + * The user a mock MSAL client signs in. + * + * @remarks + * Deliberately separate from {@link MsalClientConfig}: a client is configured + * with *what it talks to*, never with *who is signed in*. The real client learns + * the user from Entra ID, so the mock is told after it is constructed — see + * {@link MsalMockClient.setUser | setUser}. + */ +export interface MsalMockUser { + /** Display name of the signed-in user. Defaults to `Test User`. */ + name?: string; + /** UPN / email of the signed-in user. Defaults to `test.user@equinor.com`. */ + username?: string; + /** Object ID of the signed-in user. Defaults to `fusion-mock-user`. */ + userId?: string; + /** Tenant the user belongs to. Defaults to the client's configured tenant. */ + tenantId?: string; + /** Scopes granted when a request does not specify its own. */ + scopes?: string[]; + /** Preconfigured account to use for signed-in state. */ + account?: AccountInfo; + /** + * Start without a signed-in user, while keeping this identity. + * + * @remarks + * Silent flows then resolve empty so the provider follows its unauthenticated + * path, while an explicit login still succeeds *as this user*. That lets a + * test drive the sign-in journey and assert on who it ends up as, rather than + * only on its end state. + * + * Pass `null` instead of a user when the identity does not matter. + */ + signedOut?: boolean; +} + +/** + * A stand-in for the MSAL client that resolves tokens in-process. + * + * @remarks + * Constructed from {@link MsalClientConfig} — the very same argument + * {@link MsalClient} takes — so it is a drop-in substitute rather than a second + * API to learn. `setClientConfig` therefore means the same thing whether a test + * runs against Entra ID or against this client. + * + * Only the boundary that would contact Entra ID is replaced. The real + * `MsalProvider` runs on top of it unchanged, so account handling, silent-token + * preference, scope resolution, proxy providers and telemetry behave as they do in + * production — the test exercises the framework rather than the mock. + * + * Tokens are structurally valid, unsigned JWTs and are byte-identical between runs. + * They are **not** cryptographically valid and are rejected by any real service. + */ +export class MsalMockClient implements IMsalClient { + #user: Required> & { + clientId: string; + }; + #cache = new Map(); + #activeAccountId: string | null = null; + + /** + * The account currently signed in, or `null`. + * + * @remarks + * Reads through the cache rather than holding an account of its own, so an + * account removed by a sign-out cannot linger as the active one. + * + * @returns The cached active account, or `null` when none is active. + */ + get #account(): AccountInfo | null { + return this.#activeAccountId ? (this.#cache.get(this.#activeAccountId) ?? null) : null; + } + + /** + * Signs an account in, adding it to the cache and making it active. + * + * @param account - The account to sign in. + * @returns The signed-in account. + */ + #signIn(account: AccountInfo): AccountInfo { + this.#cache.set(account.homeAccountId, account); + this.#activeAccountId = account.homeAccountId; + return account; + } + + /** + * Signs the active account out, as MSAL does — the account leaves the cache. + */ + #signOut(): void { + // Only clear the cache entry when a session is active; this preserves an already signed-out state. + if (this.#activeAccountId) { + this.#cache.delete(this.#activeAccountId); + this.#activeAccountId = null; + } + } + + /** + * Mirrors MSAL's redirect completion, which is a no-op for this in-process mock. + * @returns Always `null`, because the mock performs no redirect. + */ + public async handleRedirectPromise(): Promise { + return null; + } + + /** + * Mirrors silent SSO and returns a token for the cached mock account. + * @param request - Silent SSO options and requested scopes. + * @returns A mock authentication result. + * @throws When no account is cached. + */ + public async ssoSilent(request: SsoSilentRequest): Promise { + // Silent SSO must fail without a session so providers exercise their login path. + if (!this.#account) { + throw new Error('MsalMockClient: no cached account for silent sign-in'); + } + + return this.#createResult(request.scopes); + } + + /** + * Mirrors popup login by signing in the configured mock user immediately. + * @param request - Optional popup options and requested scopes. + * @returns A mock authentication result. + */ + public async loginPopup(request?: PopupRequest): Promise { + this.#signIn(this.#createAccount()); + return this.#createResult(request?.scopes); + } + + /** + * Mirrors redirect login without navigating, because the mock has no browser boundary. + * @param _request - Redirect options, accepted for interface compatibility. + */ + public async loginRedirect(_request?: RedirectRequest): Promise { + this.#signIn(this.#createAccount()); + } + + /** + * Mirrors the framework login entry point with an immediate mock sign-in. + * @param options - Login options including the requested scopes. + * @returns A mock login result. + */ + public async login(options: LoginOptions): Promise { + this.#signIn(this.#createAccount()); + return this.#createResult((options.request as { scopes?: string[] })?.scopes); + } + + /** Mirrors logout by removing the active mock account from the cache. */ + public async logout(): Promise { + this.#signOut(); + } + + /** + * Mirrors MSAL initialization without performing a network handshake. + * @param _request - Initialization options, accepted for interface compatibility. + */ + public async initialize(_request?: InitializeApplicationRequest): Promise { + // No network handshake to perform + } + + /** + * Mirrors popup token acquisition using the mock login flow. + * @param request - Popup token request. + * @returns A mock authentication result. + */ + public async acquireTokenPopup(request: PopupRequest): Promise { + return this.loginPopup(request); + } + + /** + * Mirrors redirect token acquisition without browser navigation. + * @param request - Redirect token request. + */ + public async acquireTokenRedirect(request: RedirectRequest): Promise { + return this.loginRedirect(request); + } + + /** + * Mirrors silent token acquisition for the cached mock account. + * @param request - Silent token request. + * @returns A mock authentication result. + * @throws When no account is cached. + */ + public async acquireTokenSilent(request: SilentRequest): Promise { + // Silent acquisition must fail without a session, matching the real MSAL boundary. + if (!this.#account) { + throw new Error('MsalMockClient: no cached account for silent sign-in'); + } + return this.#createResult(request.scopes); + } + + /** + * Mirrors MSAL event registration; events are intentionally not emitted by the mock. + * @param _callback - Event handler, accepted for interface compatibility. + * @param _eventTypes - Event types, accepted for interface compatibility. + * @returns Always `null`, because the mock registers no callback. + */ + public addEventCallback( + _callback: EventCallbackFunction, + _eventTypes?: EventType[], + ): string | null { + return null; + } + + /** + * Mirrors event removal as a no-op because this mock registers no callbacks. + * @param _callbackId - Callback identifier, accepted for interface compatibility. + */ + public removeEventCallback(_callbackId: string): void { + // No-op for mock + } + + /** + * Mirrors performance callback registration with a stable mock identifier. + * @param _callback - Performance handler, accepted for interface compatibility. + * @returns A stable mock callback identifier. + */ + public addPerformanceCallback(_callback: PerformanceCallbackFunction): string { + return 'mock-performance-callback'; + } + + /** + * Mirrors performance callback removal and reports successful mock removal. + * @param _callbackId - Callback identifier, accepted for interface compatibility. + * @returns Always `true` because no callback state is retained. + */ + public removePerformanceCallback(_callbackId: string): boolean { + return true; + } + + /** + * Mirrors MSAL account lookup against the mock cache. + * @param accountFilter - Account fields to match. + * @returns The first matching account, or `null`. + */ + public getAccount(accountFilter: unknown): AccountInfo | null { + const filter = (accountFilter ?? {}) as Partial< + Pick + >; + + // Filter the cache so callers observe the same account-selection semantics as MSAL. + const matches = this.getAllAccounts().filter( + (account) => + (filter.homeAccountId === undefined || filter.homeAccountId === account.homeAccountId) && + (filter.localAccountId === undefined || filter.localAccountId === account.localAccountId) && + (filter.username === undefined || filter.username === account.username) && + (filter.tenantId === undefined || filter.tenantId === account.tenantId), + ); + + return matches[0] ?? null; + } + + /** + * Mirrors MSAL account enumeration using the mock cache. + * @param _accountFilter - Account filter, accepted for interface compatibility. + * @returns All accounts currently in the mock cache. + */ + public getAllAccounts(_accountFilter?: unknown): AccountInfo[] { + return [...this.#cache.values()]; + } + + /** + * Mirrors redirect logout without navigating in the mock environment. + * @param _request - Logout options, accepted for interface compatibility. + */ + public async logoutRedirect(_request?: EndSessionRequest): Promise { + this.#signOut(); + } + + /** + * Mirrors popup logout without opening a browser window. + * @param _request - Logout options, accepted for interface compatibility. + */ + public async logoutPopup(_request?: EndSessionPopupRequest): Promise { + this.#signOut(); + } + + /** + * Mirrors MSAL logger access; the mock does not retain a logger. + * @returns An interface-compatible empty logger value. + */ + public getLogger(): Logger { + // MSAL's Logger has a large internal surface with no mock consumers depend on; callers only pass it through + return undefined as unknown as Logger; + } + + /** + * Mirrors logger configuration as a no-op for the mock. + * @param _logger - Logger, accepted for interface compatibility. + */ + public setLogger(_logger: unknown): void { + // No-op for mock + } + + /** + * Mirrors wrapper metadata initialization as a no-op for the mock. + * @param _sku - Wrapper identifier, accepted for interface compatibility. + * @param _version - Wrapper version, accepted for interface compatibility. + */ + public initializeWrapperLibrary(_sku: WrapperSKU, _version: string): void { + // No-op for mock wrapper + } + + /** + * Mirrors navigation-client configuration as a no-op because no navigation occurs. + * @param _navigationClient - Navigation client, accepted for interface compatibility. + */ + public setNavigationClient(_navigationClient: INavigationClient): void { + // No-op for mock + } + + /** + * Mirrors configuration access and rejects it because the mock has no browser config. + * @returns Never; this mock does not expose browser configuration. + * @throws Always, because browser configuration is unsupported. + */ + public getConfiguration(): BrowserConfiguration { + throw new Error('MsalMockClient: getConfiguration is not supported in the mock client'); + } + + /** + * Mirrors cache hydration as a no-op because mock tokens are created in-process. + * @param _result - Authentication result, accepted for interface compatibility. + * @param _request - Original token request, accepted for interface compatibility. + */ + public async hydrateCache( + _result: AuthenticationResult, + _request: SilentRequest | SsoSilentRequest | RedirectRequest | PopupRequest, + ): Promise { + // No-op for mock + } + + /** + * Mirrors MSAL cache clearing by removing every mock account. + * @param _request - Cache-clear options, accepted for interface compatibility. + */ + public async clearCache(_request?: ClearCacheRequest): Promise { + this.#cache.clear(); + this.#activeAccountId = null; + } + + /** + * Mirrors the generic token acquisition entry point for the active mock account. + * @param options - Token acquisition options. + * @returns A mock result, or `null` without an active account. + */ + public async acquireToken(options: AcquireTokenOptions): Promise { + // Generic acquisition returns no result when no account is active, matching MSAL's nullable result. + if (!this.#account) { + return null; + } + return this.#createResult(options.request?.scopes); + } + + /** + * Mirrors authorization-code exchange by signing in and returning a mock result. + * @param request - Authorization-code request. + * @returns A mock authentication result. + */ + public async acquireTokenByCode( + request: AuthorizationCodeRequest, + ): Promise { + this.#signIn(this.#createAccount()); + return this.#createResult((request as { scopes?: string[] })?.scopes); + } + + /** + * Creates a mock client for the services the given configuration points at. + * + * @remarks + * Takes the same argument as {@link MsalClient}. A user named `Test User` is + * already in the account cache, so a provider built on this client boots the + * way one does for a returning user with a live session — no sign-in runs, and + * the provider's start-up path sees the state it would see in production. Use + * {@link MsalMockClient.setUser | setUser} to say who that user is. + * + * @param config - The same client configuration the real client is built from. + */ + public constructor(config: MsalClientConfig) { + const tenantId = config.auth.tenantId ?? MsalMockClient.#tenantFromAuthority(config.auth); + + this.#user = { + name: 'Test User', + username: 'test.user@equinor.com', + userId: 'fusion-mock-user', + tenantId: tenantId ?? 'fusion-mock-tenant', + scopes: ['fusion-mock-scope'], + clientId: config.auth.clientId, + }; + + this.#signIn(this.#createAccount()); + } + + /** + * Reads the tenant out of an authority URL. + * + * @remarks + * A configuration may carry only `authority`, in which case the tenant still + * has to end up on the tokens this client mints for the account to look like + * the one a real sign-in would have produced. + * + * @param auth - The auth section of the client configuration. + * @returns The tenant, or `undefined` when the authority carries none. + */ + static #tenantFromAuthority(auth: MsalClientConfig['auth']): string | undefined { + // An explicit tenant takes precedence; only parse authority when configuration omitted it. + if (!auth.authority) { + return undefined; + } + + try { + // Remove empty URL path segments to identify the authority's tenant consistently. + const segments = new URL(auth.authority).pathname.split('/').filter(Boolean); + return segments.at(-1); + } catch { + return undefined; + } + } + + /** + * Returns the client identifier used by tokens minted by this mock. + * @returns The configured client identifier. + */ + public get clientId(): string | undefined { + return this.#user.clientId; + } + + /** + * Returns the tenant identifier used by tokens minted by this mock. + * @returns The configured tenant identifier. + */ + public get tenantId(): string | undefined { + return this.#user.tenantId; + } + + /** + * Reports whether the mock currently has an active account. + * @returns Whether an account is active. + */ + public get hasValidClaims(): boolean { + return this.#account !== null; + } + + /** + * Mirrors MSAL active-account access using the mock's single active account. + * @returns The active account, or `null`. + */ + public getActiveAccount(): AccountInfo | null { + return this.#account; + } + + /** + * Makes an account the active one, adding it to the cache if it is unknown. + * + * @remarks + * Real MSAL requires the account to already be cached. Accepting an unknown + * one is a deliberate concession to tests: it is the shortest way to swap the + * signed-in user between runs, without reconstructing the framework. + * + * @param next - The account to make active, or `null` to sign out. + */ + public setActiveAccount(next: AccountInfo | null): void { + // A null account is the MSAL sign-out signal, so clear the active mock session. + if (!next) { + this.#signOut(); + return; + } + + this.#signIn(next); + } + + /** + * Declares who is signed in, replacing whoever was. + * + * @remarks + * This is the counterpart to a real sign-in: the client is configured with + * what it talks to, and learns the user separately. `MsalMockConfigurator` + * applies it as the configuration is assembled, so the account is in the cache + * before `MsalProvider.initialize` runs — the provider then behaves as it does + * for a returning user with a live session. + * + * Values left out keep whatever they were. Passing `null` signs out and + * forgets the identity, so the provider follows its unauthenticated path; + * `{ signedOut: true }` does the same but keeps the identity, so a later login + * resolves as that user. + * + * @param user - The user to sign in, or `null` when nobody is. + */ + public setUser(user: MsalMockUser | null): void { + // Declaring a user replaces the session rather than adding to it, so a test + // that names a second user does not silently end up with two cached accounts + this.#cache.clear(); + this.#activeAccountId = null; + + // A null user explicitly clears the session and identity supplied to the mock. + if (!user) { + return; + } + + const { account, signedOut, ...rest } = user; + + // Merge overrides while retaining defaults for fields omitted by the test. + this.#user = { + ...this.#user, + ...rest, + name: rest.name ?? account?.name ?? this.#user.name, + username: rest.username ?? account?.username ?? this.#user.username, + userId: rest.userId ?? account?.localAccountId ?? this.#user.userId, + tenantId: rest.tenantId ?? account?.tenantId ?? this.#user.tenantId, + scopes: rest.scopes ?? this.#user.scopes, + }; + + // Keep identity data without caching an account when the test starts signed out. + if (signedOut) { + return; + } + + this.#signIn(account ?? this.#createAccount()); + } + + /** + * Creates the one account represented by this mock's configured identity. + * @returns An MSAL-shaped account for the configured user. + */ + #createAccount(): AccountInfo { + return { + homeAccountId: `${this.#user.userId}.${this.#user.tenantId}`, + localAccountId: this.#user.userId, + environment: 'login.microsoftonline.com', + tenantId: this.#user.tenantId, + username: this.#user.username, + name: this.#user.name, + } as AccountInfo; + } + + /** + * Creates an MSAL-shaped token result for the requested or default scopes. + * @param scopes - Requested scopes, or the user's configured defaults. + * @returns An MSAL-shaped mock authentication result. + */ + #createResult(scopes?: string[]): AuthenticationResult { + const granted = scopes?.length ? scopes : this.#user.scopes; + const token = createMockToken({ + name: this.#user.name, + preferred_username: this.#user.username, + oid: this.#user.userId, + tid: this.#user.tenantId, + aud: this.#user.clientId, + scp: granted.join(' '), + }); + + // Object shape matches AuthenticationResult's fields consumers rely on; the real + // type also carries browser-only fields (e.g. `familyId`) this mock intentionally omits + return { + account: this.#account ?? this.#createAccount(), + accessToken: token, + idToken: token, + scopes: granted, + tokenType: 'Bearer', + expiresOn: new Date('2033-11-14T22:13:20.000Z'), + authority: `https://login.microsoftonline.com/${this.#user.tenantId}`, + uniqueId: this.#user.userId, + tenantId: this.#user.tenantId, + fromCache: false, + correlationId: 'fusion-mock-correlation', + } as unknown as AuthenticationResult; + } +} diff --git a/packages/modules/msal/src/mock/MsalMockConfigurator.ts b/packages/modules/msal/src/mock/MsalMockConfigurator.ts new file mode 100644 index 0000000000..d41a9edb05 --- /dev/null +++ b/packages/modules/msal/src/mock/MsalMockConfigurator.ts @@ -0,0 +1,241 @@ +import type { + ConfigBuilderCallback, + ConfigBuilderCallbackArgs, +} from '@equinor/fusion-framework-module'; + +import type { IMsalClient } from '../MsalClient.interface'; +import type { IMsalProvider } from '../MsalProvider.interface'; +import type { MsalClientConfig } from '../MsalClient'; +import { MsalConfigurator, type MsalConfig } from '../MsalConfigurator'; + +import { MsalMockClient, type MsalMockUser } from './MsalMockClient'; + +/** + * Declares the mock's own branch of the MSAL configuration. + * + * @remarks + * Merging into `MsalConfigExtension` is what lets `setAccount` record the + * user through the ordinary builder — `_set` derives its target from + * {@link MsalConfig}, so a key the type does not know about could only be set by + * casting past it. + * + * The schema strips `mock` when it validates, so a declaration made here travels + * the builder and stops there: it is readable from the raw configuration and + * absent from the one `MsalProvider` receives. + */ +declare module '../msal-config-schema' { + interface MsalConfigExtension { + mock?: { + /** + * The user to sign in, resolved if it was declared as a callback, or + * `null` when nobody is signed in. + */ + account?: MsalMockUser | null; + }; + } +} + +/** + * The client configuration used when a test declares none. + * + * @remarks + * `MsalClientConfig.auth.clientId` is required, so a mock still needs a client + * configuration to exist. Supplying a default is what lets an application boot + * under test without declaring credentials it does not have. + */ +const defaultMockClientConfig: MsalClientConfig = { + auth: { + clientId: 'fusion-mock-client', + tenantId: 'fusion-mock-tenant', + }, +}; + +/** + * The real MSAL configurator, backed by an in-process client. + * + * @remarks + * Nothing else changes: the same builder API, the same validation and the same + * `MsalProvider` are used. Only the boundary that would contact Entra ID is + * substituted, through the same + * {@link MsalConfigurator._createClient | _createClient} seam the real + * configurator builds its own client from — and from the same + * {@link MsalConfigurator._createClientConfig | _createClientConfig}, so + * `setClientConfig` means exactly what it means in production. + * + * A user named `Test User` is signed in by default, so an application boots + * without declaring anything. + * + * @example Name the signed-in user + * ```typescript + * enableMsalMock(configurator, (builder) => { + * builder.setAccount({ name: 'Ada Lovelace', username: 'ada@equinor.com' }); + * }); + * ``` + * + * @example Configure the client exactly as in production + * ```typescript + * enableMsalMock(configurator, (builder) => { + * builder.setClientConfig({ auth: { clientId: 'my-app', tenantId: 'my-tenant' } }); + * }); + * ``` + * + * @example Take full control of authentication + * ```typescript + * enableMsalMock(configurator, (builder) => { + * builder.setClient(new MyOwnMsalClient()); + * }); + * ``` + */ +export class MsalMockConfigurator extends MsalConfigurator { + /** + * Declares the user to sign in. + * + * @remarks + * Who is signed in is session state, not client configuration — which is what + * lets {@link MsalMockClient} take the same argument the real client takes: a + * client is configured with *what it talks to*, never with *who is signed in*. + * + * The user is therefore recorded on the configuration as `mock.account`, not + * on this builder, and is signed in on whichever client the module ends up + * authenticating through — wherever that client was built: + * + * - The client this builder builds, normally. The user is in place before + * `MsalProvider.initialize` runs, which is what makes the provider's own + * start-up path observable: with `signedOut` and `setRequiresAuth(true)`, a + * test sees the real automatic login run. + * - The **host's** client when the module is hoisted onto a host + * application's provider, because none is built here. An application inside + * a portal shares the portal's session, so this changes who the host sees + * signed in too, as it would in production. + * - A client supplied through {@link MsalConfigurator.setClient | setClient}, + * when that client is a {@link MsalMockClient}. + * + * Throws when that client cannot represent a declared user, rather than + * failing quietly — a silent no-op is the whole failure mode this exists to + * prevent. + * + * Pass `null` when nobody is signed in, or `{ signedOut: true }` to keep an + * identity without a session — a later login then resolves as that user. + * + * @param account - The user, or an ordinary config-builder callback resolving it. + * @returns The builder, for chaining. + * + * @example Derive the user from the modules in scope + * ```typescript + * builder.setAccount(async ({ hasModule }) => ({ + * name: hasModule('app') ? 'App User' : 'Portal User', + * })); + * ``` + */ + public setAccount( + account: MsalMockUser | null | ConfigBuilderCallback, + ): this { + this._set('mock.account', account); + return this; + } + + /** + * Signs the declared user in on the client the module authenticates through. + * + * @remarks + * Deliberately not done while the client is built: that would assume the scope + * declaring the user is the scope building the client, which is exactly what + * is not true when an application is tested inside a portal. The host built + * that client, in a scope this builder never sees, so the client has to be + * located rather than assumed. + * + * @param account - The user to sign in, or `null` when nobody is. + * @param config - The validated configuration, carrying the client when one was built. + * @param init - The builder arguments, carrying the host reference when hoisted. + * @throws When the resolved client is not a {@link MsalMockClient}. + */ + #signIn( + account: MsalMockUser | null, + config: MsalConfig, + init?: ConfigBuilderCallbackArgs, + ): void { + const host = (init?.ref as { auth?: IMsalProvider } | undefined)?.auth; + const client = config.client ?? host?.client; + + // Reject a real client because mock account state cannot be applied to it. + if (!(client instanceof MsalMockClient)) { + throw new Error( + 'MsalMockConfigurator: cannot sign a user in, because this module does not authenticate through a mock client. Declare the user where that client is configured instead.', + ); + } + + client.setUser(account); + } + + /** + * Assembles the configuration, then signs the declared user in. + * + * @remarks + * Stands a client configuration in first when this builder is the one that + * will build a client: `MsalClientConfig.auth.clientId` is required to build + * any client at all and a test has no real credentials to declare. It then + * flows through the very same + * {@link MsalConfigurator._createClientConfig | _createClientConfig} + * enrichment the real client is built from, and anything declared through + * {@link MsalConfigurator.setClientConfig | setClientConfig} wins — exactly as + * in production. + * + * Doing that here rather than in the constructor is deliberate: a hoisted + * module authenticates through the host and builds no client, so it must not + * look configured either. + * + * The user is read from `rawConfig`, because the schema strips `mock` when it + * validates — the key exists to carry a test's declaration through the + * builder, never to reach the provider. + * + * @param rawConfig - The raw configuration to process. + * @param init - The builder arguments, carrying the host reference when hoisted. + * @returns The processed and validated configuration. + */ + override async _processConfig( + rawConfig: MsalConfig, + init?: ConfigBuilderCallbackArgs, + ): Promise { + // Supply mock credentials only when this builder owns client construction. + if (!this._isHoisted(init) && !this.getClientConfig()) { + this.setClientConfig(defaultMockClientConfig); + } + + const config = await super._processConfig(rawConfig, init); + + // `null` is a declaration in its own right — nobody is signed in — so only + // an absent one means the test said nothing about the user + const account = rawConfig.mock?.account; + // Apply even null because null explicitly requests a signed-out mock state. + if (account !== undefined) { + this.#signIn(account, config, init); + } + + return config; + } + + /** + * Builds an in-process client. + * + * @remarks + * Called only when no client was set, so + * {@link MsalConfigurator.setClient | setClient} still replaces authentication + * outright. + * + * Deliberately does not delegate to `super`, which would build a real + * `MsalClient` and contact Entra ID. It is never reached when the module is + * hoisted onto a host application's provider, because the base configurator + * gates client creation on {@link MsalConfigurator._isHoisted | _isHoisted} — + * a mock client built there would shadow the host's client, the exact scenario + * an application-inside-a-portal test exists to cover. + * + * Knows nothing about who is signed in: a client is built from what it talks + * to, and the declared user is applied to it afterwards. + * + * @param config - The validated configuration the client is built from. + * @returns A client resolving tokens in-process. + */ + protected override async _createClient(config: MsalConfig): Promise { + return new MsalMockClient(this._createClientConfig(config) ?? defaultMockClientConfig); + } +} diff --git a/packages/modules/msal/src/mock/create-mock-token.ts b/packages/modules/msal/src/mock/create-mock-token.ts new file mode 100644 index 0000000000..ec4d0bfbb7 --- /dev/null +++ b/packages/modules/msal/src/mock/create-mock-token.ts @@ -0,0 +1,92 @@ +/** + * Claims that can be set on a generated mock token. + * + * @remarks + * Mirrors the subset of Entra ID claims that Fusion applications read. Any + * additional claims are passed through unchanged. + */ +export interface MockTokenClaims { + /** Object ID of the signed-in user. */ + oid?: string; + /** Display name of the signed-in user. */ + name?: string; + /** UPN / email of the signed-in user. */ + preferred_username?: string; + /** Tenant the token was issued for. */ + tid?: string; + /** Audience — normally the client or resource the token is intended for. */ + aud?: string; + /** Issuer. */ + iss?: string; + /** Scopes granted, space-separated as in a real Entra ID token. */ + scp?: string; + /** Issued-at, seconds since epoch. */ + iat?: number; + /** Not-before, seconds since epoch. */ + nbf?: number; + /** Expiry, seconds since epoch. */ + exp?: number; + [claim: string]: unknown; +} + +/** + * Encodes a value as base64url without padding, as used in JWT segments. + * + * @param value - Raw string to encode. + * @returns The base64url representation. + */ +const base64Url = (value: string): string => { + // btoa operates on latin1; encodeURIComponent round-trip keeps non-ASCII names intact + const bytes = new TextEncoder().encode(value); + let binary = ''; + // Iterate over encoded bytes so Unicode claims are preserved before base64url encoding. + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +/** + * Creates a structurally valid, unsigned JWT for use in tests. + * + * The token has three base64url segments and decodes to the supplied claims, so + * code that splits, decodes, or inspects token claims behaves exactly as it does + * in production. The signature segment is a fixed placeholder — the token is + * **not** cryptographically valid and will be rejected by any real service. + * + * @remarks + * Timestamps default to a fixed issue time and a one-hour lifetime so repeated + * runs produce byte-identical tokens. A test that needs an expired token can set + * `exp` in the past. + * + * @param claims - Claims to embed in the token payload. + * @returns An unsigned JWT string in `header.payload.signature` form. + * + * @example + * ```typescript + * const token = createMockToken({ name: 'Test User', scp: 'Files.Read' }); + * const [, payload] = token.split('.'); + * JSON.parse(decodeJwtSegment(payload)).name; // 'Test User' + * ``` + */ +export const createMockToken = (claims: MockTokenClaims = {}): string => { + const header = { alg: 'none', typ: 'JWT' }; + // Fixed default clock keeps generated tokens byte-identical between runs + const issuedAt = claims.iat ?? 1_700_000_000; + const payload: MockTokenClaims = { + iss: 'https://login.microsoftonline.com/fusion-test-tenant/v2.0', + aud: 'fusion-test-client', + tid: 'fusion-test-tenant', + oid: 'fusion-test-user', + iat: issuedAt, + nbf: issuedAt, + exp: issuedAt + 3600, + ...claims, + }; + + return [ + base64Url(JSON.stringify(header)), + base64Url(JSON.stringify(payload)), + 'fusion-test-signature', + ].join('.'); +}; diff --git a/packages/modules/msal/src/mock/create-msal-mock-client.ts b/packages/modules/msal/src/mock/create-msal-mock-client.ts new file mode 100644 index 0000000000..251deed6cb --- /dev/null +++ b/packages/modules/msal/src/mock/create-msal-mock-client.ts @@ -0,0 +1,25 @@ +import type { IMsalClient } from '../MsalClient.interface'; +import type { MsalClientConfig } from '../MsalClient'; +import { MsalMockClient, type MsalMockUser } from './MsalMockClient'; + +/** + * Convenience helper that creates a mock client instance. + * + * @remarks + * The class form is preferred for a more familiar configuration pattern. + * + * @param config - The same client configuration the real client is built from. + * @param user - Optional user to sign in, applied after construction. + * @returns A client that resolves tokens in-process. + */ +export const createMsalMockClient = ( + config: MsalClientConfig, + user?: MsalMockUser, +): IMsalClient => { + const client = new MsalMockClient(config); + // Apply the optional identity after construction so the helper matches setUser semantics. + if (user) { + client.setUser(user); + } + return client; +}; diff --git a/packages/modules/msal/src/mock/decode-jwt-segment.ts b/packages/modules/msal/src/mock/decode-jwt-segment.ts new file mode 100644 index 0000000000..0e4b4aeae6 --- /dev/null +++ b/packages/modules/msal/src/mock/decode-jwt-segment.ts @@ -0,0 +1,22 @@ +/** + * Decodes a base64url segment of a {@link createMockToken} JWT back to its JSON string. + * + * @remarks + * Plain `atob` alone mangles non-ASCII claims: it treats its output as latin1, + * while segments are UTF-8 encoded. This reverses that encoding and also + * restores the standard base64 alphabet/padding `atob` expects. + * + * @param segment - A base64url segment, e.g. from splitting a JWT on `.`. + * @returns The decoded UTF-8 string. + */ +export function decodeJwtSegment(segment: string): string { + const base64 = segment + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(segment.length + ((4 - (segment.length % 4)) % 4), '='); + const binary = atob(base64); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +export default decodeJwtSegment; diff --git a/packages/modules/msal/src/mock/index.ts b/packages/modules/msal/src/mock/index.ts new file mode 100644 index 0000000000..14ceb56b2e --- /dev/null +++ b/packages/modules/msal/src/mock/index.ts @@ -0,0 +1,29 @@ +/** + * Mock MSAL for tests: real provider, real configurator, fake client. + * + * @remarks + * Substituting the client is the smallest change that removes Entra ID from a test. + * Everything above it — scope resolution, silent-first token acquisition, account + * handling, proxy providers, telemetry — is the production code path. + * + * @example + * ```typescript + * import { enableMsalMock } from '@equinor/fusion-framework-module-msal/mock'; + * + * // default mock user + * enableMsalMock(configurator); + * + * // or a specific one + * enableMsalMock(configurator, (builder) => { + * builder.setAccount({ name: 'Ada Lovelace', signedOut: true }); + * }); + * ``` + * + * @packageDocumentation + */ +export { MsalMockClient, type MsalMockUser } from './MsalMockClient'; +export { createMsalMockClient } from './create-msal-mock-client'; +export { MsalMockConfigurator } from './MsalMockConfigurator'; +export { enableMsalMock, msalMockModule, type AuthConfigMockFn } from './module'; +export { createMockToken, type MockTokenClaims } from './create-mock-token'; +export { decodeJwtSegment } from './decode-jwt-segment'; diff --git a/packages/modules/msal/src/mock/module.ts b/packages/modules/msal/src/mock/module.ts new file mode 100644 index 0000000000..798de586b9 --- /dev/null +++ b/packages/modules/msal/src/mock/module.ts @@ -0,0 +1,54 @@ +import type { IModulesConfigurator } from '@equinor/fusion-framework-module'; + +import { module, type MsalModule } from '../module'; + +import { MsalMockConfigurator } from './MsalMockConfigurator'; + +/** + * The MSAL module with a mock client instead of a live connection to Entra ID. + * + * @remarks + * Only `configure` differs from the real module. `initialize` is the production + * one, untouched, so proxy providers, host-provider hoisting and provider + * initialization all behave exactly as they do in production — and a test + * observes the real start-up path rather than a rehearsal of it. + */ +export const msalMockModule: MsalModule = { + ...module, + configure: () => new MsalMockConfigurator(), +}; + +/** + * Configuration callback for {@link enableMsalMock}. + */ +export type AuthConfigMockFn = ( + configurator: MsalMockConfigurator, + ref?: TRef, +) => void; + +/** + * Enables MSAL against a mock client, so a test needs no credentials and no network. + * + * @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 override the default mock client. + * + * @example + * ```typescript + * enableMsalMock(configurator, (builder) => { + * builder.setAccount({ name: 'Ada Lovelace' }); + * }); + * ``` + */ +export const enableMsalMock = ( + // biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types + configurator: IModulesConfigurator, + configure?: AuthConfigMockFn, +): void => { + configurator.addConfig({ module: msalMockModule, configure } as { + module: MsalModule; + }); +}; diff --git a/packages/modules/msal/src/msal-config-schema.ts b/packages/modules/msal/src/msal-config-schema.ts new file mode 100644 index 0000000000..f08b5cabb8 --- /dev/null +++ b/packages/modules/msal/src/msal-config-schema.ts @@ -0,0 +1,81 @@ +import z from 'zod'; +import semver from 'semver'; +import { CacheLookupPolicy } from '@azure/msal-browser'; + +import type { IMsalClient } from './MsalClient.interface'; +import type { IMsalProvider } from './MsalProvider.interface'; +import { TelemetryConfigSchema } from './telemetry-config-schema'; +export type { TelemetryConfig } from './telemetry-config-schema'; + +/** + * Zod schema for MSAL module configuration validation. + * + * @remarks + * Kept in its own module so the configuration can be extended at its source. + * The schema itself describes what reaches `MsalProvider` and strips anything + * else; keys a variant of this module needs only while the configuration is + * being built are declared on {@link MsalConfigExtension} instead. + */ +export const MsalConfigSchema = z.object({ + client: z.custom().optional(), + provider: z.custom().optional(), + requiresAuth: z.boolean().optional(), + redirectUri: z.string().optional(), + loginHint: z.string().optional(), + authCode: z.string().optional(), + cacheLookupPolicy: z + .custom( + (val) => + typeof val === 'number' && + Object.values(CacheLookupPolicy).includes(val as CacheLookupPolicy), + ) + .optional(), + version: z.string().transform((value, ctx) => { + const coerced = semver.coerce(value); + // `semver.coerce` returns `null` for an unparseable version; without this guard it + // would silently become the literal string "null" instead of failing validation. + if (!coerced) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid MSAL module version' }); + return z.NEVER; + } + return coerced.version; + }), + telemetry: TelemetryConfigSchema, +}); + +/** + * Configuration a variant of this module adds to {@link MsalConfig}. + * + * @remarks + * Empty by design: production MSAL configuration is exactly the schema. This + * exists so a variant — the test double in `./mock`, for instance — can declare + * its own branch of the configuration through declaration merging: + * + * ```typescript + * declare module '@equinor/fusion-framework-module-msal' { + * interface MsalConfigExtension { + * mock?: { account?: MsalMockUser }; + * } + * } + * ``` + * + * That is what keeps `BaseConfigBuilder._set` honest about the added key. Its + * target is a dot-path union derived from {@link MsalConfig}, so a key the type + * does not know about can only be set by casting past the builder — and a + * generic configurator cannot help, because a dot-path union over an unresolved + * type parameter defers, taking every existing literal path down with it. + * + * The key exists to carry a declaration across the builder, not to reach the + * provider: the schema strips it during validation, so it is readable from the + * raw configuration and absent from the validated one. + */ +// biome-ignore lint/suspicious/noEmptyInterface: the extension point is the point +export interface MsalConfigExtension {} + +/** + * Complete configuration object for MSAL authentication module. + * + * This type represents the full configuration including client setup, authentication + * requirements, telemetry, and version information. + */ +export type MsalConfig = z.infer & MsalConfigExtension; diff --git a/packages/modules/msal/src/telemetry-config-schema.ts b/packages/modules/msal/src/telemetry-config-schema.ts new file mode 100644 index 0000000000..5f13d94768 --- /dev/null +++ b/packages/modules/msal/src/telemetry-config-schema.ts @@ -0,0 +1,25 @@ +import z from 'zod'; +import type { ITelemetryProvider } from '@equinor/fusion-framework-module-telemetry'; +import { version } from './version'; + +/** + * Zod schema for telemetry configuration validation. + * + * @internal + */ +export const TelemetryConfigSchema = z.object({ + provider: z.custom().optional(), + metadata: z.record(z.string(), z.unknown()).optional().default({ + module: 'msal', + version, + }), + scope: z.array(z.string()).optional().default(['framework', 'authentication']), +}); + +/** + * Telemetry configuration for MSAL module. + * + * This configuration controls how authentication events are tracked and logged + * through the framework's telemetry system. + */ +export type TelemetryConfig = z.infer; diff --git a/packages/modules/service-discovery/README.md b/packages/modules/service-discovery/README.md index da3c43f374..39b8b1a084 100644 --- a/packages/modules/service-discovery/README.md +++ b/packages/modules/service-discovery/README.md @@ -57,77 +57,37 @@ const client = await modules.serviceDiscovery.createClient('people'); const data = await client.fetchAsync('/persons?search=Jane'); ``` -## Configuration +## Documentation -### Simple — Custom HTTP Client Key +| Guide | Covers | +| --- | --- | +| [Configuration](./docs/configuration.md) | Custom HTTP client keys, custom clients, and replacing the discovery client | +| [Testing](./docs/testing.md) | The `/mock` entry point: an in-memory registry, local mock servers, and spying | +| [Session Overrides](./docs/session-overrides.md) | Redirecting services to local or staging URLs during development | +| [API Reference](./docs/api-reference.md) | Exports and the `Service` shape | -If the HTTP client is registered under a key other than `"service_discovery"`: +## Testing -```typescript -enableServiceDiscovery(configurator, async (builder) => { - builder.configureServiceDiscoveryClientByClientKey( - 'sd_custom', // HTTP client key - '/custom/services', // optional endpoint path - ); -}); -``` - -### Intermediate — Custom HTTP Client - -Supply your own HTTP client and endpoint: +Import from `@equinor/fusion-framework-module-service-discovery/mock` to resolve services from an in-memory registry instead of the service registry. The real configurator, provider and validation still run — only the boundary that would contact the registry is substituted. ```typescript -enableServiceDiscovery(configurator, async (builder) => { - builder.configureServiceDiscoveryClient(async ({ requireInstance }) => { - const httpProvider = await requireInstance('http'); - return { - httpClient: httpProvider.createClient('my_key'), - endpoint: '/custom/services', - }; - }); -}); -``` +import { enableServiceDiscoveryMock } from '@equinor/fusion-framework-module-service-discovery/mock'; -### Advanced — Fully Custom Discovery Client +enableServiceDiscoveryMock(configurator); +``` -Provide an object implementing `IServiceDiscoveryClient` directly: +Baseline services (`apps`, `people`, `context`, `bookmarks`, `notification`) resolve out of the box. The configurator owns the registry, so pointing every service at a locally running mock server takes no client: ```typescript -enableServiceDiscovery(configurator, async (builder) => { - builder.setServiceDiscoveryClient({ - async resolveServices() { - return [ - { key: 'api', uri: 'https://localhost:5000', defaultScopes: [] }, - ]; - }, - async resolveService(key) { - const services = await this.resolveServices(); - const service = services.find((s) => s.key === key); - if (!service) throw new Error(`Unknown service: ${key}`); - return service; - }, - }); +enableServiceDiscoveryMock(configurator, (builder) => { + builder.setBaseUri('http://localhost:6669'); + builder.addService({ key: 'my-api' }); }); ``` -Or use an async factory for access to the build environment: +The entry point has **no test-runner dependency**, and ships no mocking API of its own — spying on a call is your test runner's job. -```typescript -enableServiceDiscovery(configurator, async (builder) => { - builder.setServiceDiscoveryClient(async ({ requireInstance }) => { - const httpProvider = await requireInstance('http'); - const httpClient = httpProvider.createClient('my_key'); - return { - async resolveServices() { - return httpClient.fetchAsync('/services'); - }, - async resolveService(key) { - return httpClient.fetchAsync(`/services/${key}`); - }, - }; - }); -}); -``` +See [Testing](./docs/testing.md) for the full builder API, local-mock-server setup and runner guidance, or [`@equinor/fusion-framework/mock`](../../framework/docs/testing.md) to mock every framework boundary at once. ## Key Concepts @@ -142,67 +102,3 @@ When used inside a sub-module (e.g. an Application), the Service Discovery modul ### Caching The built-in `ServiceDiscoveryClient` caches results for **5 minutes** via `@equinor/fusion-query`. The `allow_cache` parameter on `resolveService` / `resolveServices` controls whether to return the first cached snapshot (`true`) or wait for the latest response (`false`, the default). - -### Session Overrides - -> [!TIP] -> Session overrides let you redirect services to local or staging URLs during development without touching application config. - -Store a JSON object in `sessionStorage` under the key `"overriddenServiceDiscoveryUrls"`: - -```typescript -const overrides = { - 'my-api': { - url: 'https://localhost:3000/api', - scopes: ['https://localhost/.default'], - }, -}; -sessionStorage.setItem('overriddenServiceDiscoveryUrls', JSON.stringify(overrides)); -``` - -How it works: - -1. Services are fetched normally from the API -2. The module checks `sessionStorage` for overrides -3. Matching services get their `uri` and `scopes` replaced, and an `overridden: true` flag is set -4. Overrides are only applied when `sessionStorage` is available - -Clear overrides by removing the storage key: - -```typescript -sessionStorage.removeItem('overriddenServiceDiscoveryUrls'); -``` - -> [!NOTE] -> Session overrides are temporary — they are cleared when the browser session ends and only affect the current tab/window. - -## API Reference - -### Exports - -| Export | Kind | Description | -| ---------------------------------- | ------------- | -------------------------------------------------------------- | -| `enableServiceDiscovery` | function | Registers the module on a `ModulesConfigurator` (recommended) | -| `configureServiceDiscovery` | function | Creates an `IModuleConfigurator` for manual `addConfig` usage | -| `ServiceDiscoveryConfigurator` | class | Builder for service discovery configuration | -| `ServiceDiscoveryProvider` | class | Runtime provider — resolves services, creates HTTP clients | -| `IServiceDiscoveryProvider` | interface | Public API contract for the provider | -| `IServiceDiscoveryClient` | interface | Contract for pluggable discovery client implementations | -| `Service` | type | Shape of a resolved service endpoint | -| `ServiceDiscoveryConfig` | interface | Resolved module configuration holding the discovery client | -| `ServiceDiscoveryModule` | type | Module type alias for the framework module system | - -### `Service` Shape - -```typescript -type Service = { - key: string; // Lookup key (e.g. "context") - uri: string; // Base URI of the service - scopes?: string[]; // OAuth scopes - id?: string; // Service registration ID - name?: string; // Display name - tags?: string[]; // Freeform tags - overridden?: boolean; // True when session-overridden - defaultScopes: string[]; // @deprecated — use `scopes` -}; -``` \ No newline at end of file diff --git a/packages/modules/service-discovery/docs/api-reference.md b/packages/modules/service-discovery/docs/api-reference.md new file mode 100644 index 0000000000..d556abbdb2 --- /dev/null +++ b/packages/modules/service-discovery/docs/api-reference.md @@ -0,0 +1,30 @@ +# Service Discovery API Reference + +## Exports + +| Export | Kind | Description | +| ---------------------------------- | ------------- | -------------------------------------------------------------- | +| `enableServiceDiscovery` | function | Registers the module on a `ModulesConfigurator` (recommended) | +| `configureServiceDiscovery` | function | Creates an `IModuleConfigurator` for manual `addConfig` usage | +| `ServiceDiscoveryConfigurator` | class | Builder for service discovery configuration | +| `ServiceDiscoveryProvider` | class | Runtime provider — resolves services, creates HTTP clients | +| `IServiceDiscoveryProvider` | interface | Public API contract for the provider | +| `IServiceDiscoveryClient` | interface | Contract for pluggable discovery client implementations | +| `Service` | type | Shape of a resolved service endpoint | +| `ServiceDiscoveryConfig` | interface | Resolved module configuration holding the discovery client | +| `ServiceDiscoveryModule` | type | Module type alias for the framework module system | + +## `Service` Shape + +```typescript +type Service = { + key: string; // Lookup key (e.g. "context") + uri: string; // Base URI of the service + scopes?: string[]; // OAuth scopes + id?: string; // Service registration ID + name?: string; // Display name + tags?: string[]; // Freeform tags + overridden?: boolean; // True when session-overridden + defaultScopes: string[]; // @deprecated — use `scopes` +}; +``` diff --git a/packages/modules/service-discovery/docs/configuration.md b/packages/modules/service-discovery/docs/configuration.md new file mode 100644 index 0000000000..172aabea46 --- /dev/null +++ b/packages/modules/service-discovery/docs/configuration.md @@ -0,0 +1,77 @@ +# Configuration + +Three levels of control over how services are discovered, from swapping the HTTP client key to replacing the discovery client outright. + +## Simple — Custom HTTP Client Key + +If the HTTP client is registered under a key other than `"service_discovery"`: + +```typescript +enableServiceDiscovery(configurator, async (builder) => { + builder.configureServiceDiscoveryClientByClientKey( + 'sd_custom', // HTTP client key + '/custom/services', // optional endpoint path + ); +}); +``` + +## Intermediate — Custom HTTP Client + +Supply your own HTTP client and endpoint: + +```typescript +enableServiceDiscovery(configurator, async (builder) => { + builder.configureServiceDiscoveryClient(async ({ requireInstance }) => { + const httpProvider = await requireInstance('http'); + return { + httpClient: httpProvider.createClient('my_key'), + endpoint: '/custom/services', + }; + }); +}); +``` + +## Advanced — Fully Custom Discovery Client + +> [!TIP] +> Do **not** reach for this to write a test. The module ships a test double at +> `@equinor/fusion-framework-module-service-discovery/mock` — see [Testing](./testing.md). + +Provide an object implementing `IServiceDiscoveryClient` directly: + +```typescript +enableServiceDiscovery(configurator, async (builder) => { + builder.setServiceDiscoveryClient({ + async resolveServices() { + return [ + { key: 'api', uri: 'https://localhost:5000', defaultScopes: [] }, + ]; + }, + async resolveService(key) { + const services = await this.resolveServices(); + const service = services.find((s) => s.key === key); + if (!service) throw new Error(`Unknown service: ${key}`); + return service; + }, + }); +}); +``` + +Or use an async factory for access to the build environment: + +```typescript +enableServiceDiscovery(configurator, async (builder) => { + builder.setServiceDiscoveryClient(async ({ requireInstance }) => { + const httpProvider = await requireInstance('http'); + const httpClient = httpProvider.createClient('my_key'); + return { + async resolveServices() { + return httpClient.fetchAsync('/services'); + }, + async resolveService(key) { + return httpClient.fetchAsync(`/services/${key}`); + }, + }; + }); +}); +``` diff --git a/packages/modules/service-discovery/docs/session-overrides.md b/packages/modules/service-discovery/docs/session-overrides.md new file mode 100644 index 0000000000..cd302ac389 --- /dev/null +++ b/packages/modules/service-discovery/docs/session-overrides.md @@ -0,0 +1,32 @@ +# Session Overrides + +> [!TIP] +> Session overrides let you redirect services to local or staging URLs during development without touching application config. + +Store a JSON object in `sessionStorage` under the key `"overriddenServiceDiscoveryUrls"`: + +```typescript +const overrides = { + 'my-api': { + url: 'https://localhost:3000/api', + scopes: ['https://localhost/.default'], + }, +}; +sessionStorage.setItem('overriddenServiceDiscoveryUrls', JSON.stringify(overrides)); +``` + +How it works: + +1. Services are fetched normally from the API +2. The module checks `sessionStorage` for overrides +3. Matching services get their `uri` and `scopes` replaced, and an `overridden: true` flag is set +4. Overrides are only applied when `sessionStorage` is available + +Clear overrides by removing the storage key: + +```typescript +sessionStorage.removeItem('overriddenServiceDiscoveryUrls'); +``` + +> [!NOTE] +> Session overrides are temporary — they are cleared when the browser session ends and only affect the current tab/window. diff --git a/packages/modules/service-discovery/docs/testing.md b/packages/modules/service-discovery/docs/testing.md new file mode 100644 index 0000000000..daa9c7338a --- /dev/null +++ b/packages/modules/service-discovery/docs/testing.md @@ -0,0 +1,127 @@ +# Service Discovery — test double + +Resolve services from an in-memory registry instead of the service registry. + +```typescript +import { enableServiceDiscoveryMock } from '@equinor/fusion-framework-module-service-discovery/mock'; + +enableServiceDiscoveryMock(configurator); +``` + +Import path: `@equinor/fusion-framework-module-service-discovery/mock`. The entry point has **no test-runner dependency**. + +## What is substituted + +> [!IMPORTANT] +> Only `IServiceDiscoveryClient` — the object that would contact the service registry. The real `ServiceDiscoveryConfigurator`, the real `ServiceDiscoveryProvider` and the real configuration validation all still run. + +That distinction is the point: a test still exercises scope resolution, HTTP client creation and module wiring, so it still catches mistakes there. A double that replaced the provider would have skipped that logic and reported whatever it was told to. + +## Defaults + +Baseline services resolve out of the box, so an application boots without declaring anything: + +| Key | Resolves to | +| --- | --- | +| `apps` | `https://apps.fusion.test` | +| `people` | `https://people.fusion.test` | +| `context` | `https://context.fusion.test` | +| `bookmarks` | `https://bookmarks.fusion.test` | +| `notification` | `https://notification.fusion.test` | + +An **undeclared** key resolves to a synthesised entry rather than throwing, so a test does not fail merely because the application resolved something the test did not think to declare. Call `setResolveUnknownServices(false)` to assert the opposite. + +Resolution is deterministic across runs and machines. + +## Build the registry on the builder + +> [!IMPORTANT] +> You never construct a client to add a service or move services to another host. The **configurator owns the registry**; the client is built from it when the module assembles its config. Ordering of builder calls therefore does not matter. + +```typescript +enableServiceDiscoveryMock(configurator, (builder) => { + builder.setBaseUri('http://localhost:6669'); + builder.addService({ key: 'my-api' }); + builder.removeService('bookmarks'); +}); +``` + +| Method | Purpose | +| --- | --- | +| `setBaseUri(uri)` / `getBaseUri()` | Resolve every service without an explicit `uri` against this host | +| `addService(service)` / `addServices(services)` | Register services, replacing existing declarations by `key` | +| `removeService(key)` | Drop a service, including a baseline one | +| `setServices(services)` / `getServices()` | Replace the registry outright | +| `setResolveUnknownServices(boolean)` | Throw instead of synthesising unknown services | +| `configure(options)` | Apply `{ baseUri, services, resolveUnknownServices }` in one call | + +A declaration only requires `key`; `uri`, `name` and `scopes` are derived from it. An explicit `uri` on a service always wins over `baseUri`. + +Every method returns the builder, so calls chain. + +## Running against a local mock server + +`setBaseUri` is the hook for Mockoon, Prism or the Fusion dev server. With `http://localhost:6669`, `apps` resolves to `http://localhost:6669/apps`, so the application makes **real HTTP calls to a real local server**. + +Nothing intercepts requests and no service worker is involved — the transport is exercised as it is in production. + +```typescript +enableServiceDiscoveryMock(configurator, (builder) => { + builder.setBaseUri('http://localhost:6669'); +}); +``` + +## Mocking an individual call + +> [!IMPORTANT] +> That is your test runner's job. This module ships **no mocking API**. + +The mock client is a plain class with ordinary methods, so `vi.spyOn`, `bun:test`'s `spyOn` and Node's `t.mock.method` all work on it directly — with their own call assertions, argument matchers and reset semantics, which a framework-specific API would not give you. + +The provider exposes the client it resolves through, so a spy has a stable target: + +```typescript +vi.spyOn(fusion.modules.serviceDiscovery.client, 'resolveService').mockResolvedValue(service); + +afterEach(() => vi.restoreAllMocks()); +``` + +## One-call shorthand + +`mockServiceDiscovery` registers the module and applies options in a single call. Use it when the callback alone would be noise: + +```typescript +import { mockServiceDiscovery } from '@equinor/fusion-framework-module-service-discovery/mock'; + +mockServiceDiscovery(configurator, { baseUri: 'http://localhost:6669' }); +``` + +> [!NOTE] +> The `services` option **replaces** the baseline registry. To keep the defaults and add to them, use `addService` on the builder. + +## Taking full control + +When resolution itself is the thing under test, register your own client — the same seam an application uses: + +```typescript +enableServiceDiscoveryMock(configurator, (builder) => { + builder.setServiceDiscoveryClient(new MyOwnDiscoveryClient()); +}); +``` + +## Exports + +| Export | Purpose | +| --- | --- | +| `enableServiceDiscoveryMock(configurator, configure?)` | Register the module with an in-memory registry | +| `mockServiceDiscovery(configurator, options?, configure?)` | One-call shorthand over the above | +| `serviceDiscoveryMockModule` | The module itself, for manual registration | +| `ServiceDiscoveryMockConfigurator` | The real configurator, building an in-memory registry | +| `ServiceDiscoveryMockClient` | The in-memory client, if you need one standalone | +| `defaultServiceDiscoveryMockServices` | The baseline registry | +| `createMockService(service, baseUri?)` | Expand a sparse declaration into a full `Service` | + +## Related + +- [Module README](../README.md) — production configuration +- [`@equinor/fusion-framework/mock`](../../../framework/docs/testing.md) — mock every framework boundary at once diff --git a/packages/modules/service-discovery/package.json b/packages/modules/service-discovery/package.json index cf28487768..5bcf310dc8 100644 --- a/packages/modules/service-discovery/package.json +++ b/packages/modules/service-discovery/package.json @@ -7,12 +7,27 @@ ".": { "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js" + }, + "./mock": { + "types": "./dist/types/mock/index.d.ts", + "import": "./dist/esm/mock/index.js" + } + }, + "typesVersions": { + "*": { + ".": [ + "dist/types/index.d.ts" + ], + "mock": [ + "dist/types/mock/index.d.ts" + ] } }, "types": "dist/types/index.d.ts", "scripts": { "build": "tsc -b", - "prepack": "pnpm build" + "prepack": "pnpm build", + "test": "vitest" }, "keywords": [], "author": "", diff --git a/packages/modules/service-discovery/src/__tests__/mock/service-discovery-mock.test.ts b/packages/modules/service-discovery/src/__tests__/mock/service-discovery-mock.test.ts new file mode 100644 index 0000000000..b8e3ad2708 --- /dev/null +++ b/packages/modules/service-discovery/src/__tests__/mock/service-discovery-mock.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ModulesConfigurator } from '@equinor/fusion-framework-module'; +import httpModule from '@equinor/fusion-framework-module-http'; +import { module as serviceDiscoveryModule } from '../../module'; +import { + createMockService, + defaultServiceDiscoveryMockServices, + enableServiceDiscoveryMock, + mockServiceDiscovery, + ServiceDiscoveryMockClient, + ServiceDiscoveryMockConfigurator, + serviceDiscoveryMockModule, +} from '../../mock'; +import type { Service } from '../../types'; + +/** Resolves a service through a fully initialized module instance. */ +const resolveThroughModule = async ( + // biome-ignore lint/suspicious/noExplicitAny: mirrors the enabler signature, which accepts any configurator scope + configurator: ModulesConfigurator, + key: string, +): Promise => { + const instances = (await configurator.initialize()) as unknown as { + serviceDiscovery: { resolveService: (key: string) => Promise }; + }; + return instances.serviceDiscovery.resolveService(key); +}; + +describe('createMockService', () => { + it('derives uri, name and scopes from the key alone', () => { + expect(createMockService({ key: 'apps' })).toEqual({ + key: 'apps', + uri: 'https://apps.fusion.test', + name: 'apps', + scopes: ['apps/.default'], + defaultScopes: ['apps/.default'], + }); + }); + + it('resolves against a local server when a base uri is supplied', () => { + expect(createMockService({ key: 'apps' }, 'http://localhost:3000/')).toEqual( + expect.objectContaining({ uri: 'http://localhost:3000/apps' }), + ); + }); + + it('lets an explicit uri win over the base uri', () => { + expect( + createMockService({ key: 'apps', uri: 'https://apps.test' }, 'http://localhost:3000'), + ).toEqual(expect.objectContaining({ uri: 'https://apps.test' })); + }); +}); + +describe('defaultServiceDiscoveryMockServices', () => { + it('covers the services a Fusion application resolves at start-up', () => { + expect(defaultServiceDiscoveryMockServices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'apps' }), + expect.objectContaining({ key: 'people' }), + expect.objectContaining({ key: 'context' }), + ]), + ); + }); +}); + +describe('ServiceDiscoveryMockClient', () => { + it('serves the baseline services when none are supplied', async () => { + const client = new ServiceDiscoveryMockClient(); + + expect((await client.resolveServices()).map((service) => service.key)).toEqual( + defaultServiceDiscoveryMockServices.map((service) => service.key), + ); + }); + + it('replaces the baseline with the supplied services', async () => { + const client = new ServiceDiscoveryMockClient({ services: [{ key: 'only-this' }] }); + + expect((await client.resolveServices()).map((service) => service.key)).toEqual(['only-this']); + }); + + it('resolves unknown keys by default', async () => { + const client = new ServiceDiscoveryMockClient(); + + expect(await client.resolveService('unknown')).toEqual( + expect.objectContaining({ key: 'unknown', uri: 'https://unknown.fusion.test' }), + ); + }); + + it('throws for unknown keys when unknown services are disallowed', async () => { + const client = new ServiceDiscoveryMockClient({ resolveUnknownServices: false }); + + await expect(client.resolveService('unknown')).rejects.toThrow(/not registered/); + }); + + it('points every service at a local mock server when given a base uri', async () => { + const client = new ServiceDiscoveryMockClient({ baseUri: 'http://localhost:4000' }); + + expect(await client.resolveService('apps')).toEqual( + expect.objectContaining({ uri: 'http://localhost:4000/apps' }), + ); + expect(await client.resolveService('unknown')).toEqual( + expect.objectContaining({ uri: 'http://localhost:4000/unknown' }), + ); + }); + + it('lets a test runner spy on a single method', async () => { + const client = new ServiceDiscoveryMockClient(); + const spy = vi + .spyOn(client, 'resolveService') + .mockImplementation(async (key) => createMockService({ key: `${key}-stubbed` })); + + expect((await client.resolveService('apps')).key).toBe('apps-stubbed'); + // Untouched methods keep their real behaviour + expect(await client.resolveServices()).not.toHaveLength(0); + + spy.mockRestore(); + + expect((await client.resolveService('apps')).key).toBe('apps'); + }); +}); + +describe('ServiceDiscoveryMockConfigurator', () => { + it('starts from the baseline registry', () => { + const configurator = new ServiceDiscoveryMockConfigurator(); + + expect(configurator.getServices().map((service) => service.key)).toEqual( + defaultServiceDiscoveryMockServices.map((service) => service.key), + ); + }); + + it('composes the registry without constructing a client', () => { + const configurator = new ServiceDiscoveryMockConfigurator(); + + configurator + .setBaseUri('http://localhost:6669') + .addService({ key: 'my-api' }) + .removeService('bookmarks'); + + expect(configurator.getBaseUri()).toBe('http://localhost:6669'); + expect(configurator.getServices().map((service) => service.key)).toContain('my-api'); + expect(configurator.getServices().map((service) => service.key)).not.toContain('bookmarks'); + }); + + it('replaces the baseline when services are set outright', () => { + const configurator = new ServiceDiscoveryMockConfigurator({ services: [{ key: 'only-this' }] }); + + expect(configurator.getServices().map((service) => service.key)).toEqual(['only-this']); + }); +}); + +describe('serviceDiscoveryMockModule', () => { + it('matches the real module name and initialize path', () => { + expect(serviceDiscoveryMockModule.name).toBe(serviceDiscoveryModule.name); + expect(serviceDiscoveryMockModule.initialize).toBe(serviceDiscoveryModule.initialize); + }); + + it('builds a real ServiceDiscoveryConfigurator with a mock registry preconfigured', async () => { + const configurator = + (await serviceDiscoveryMockModule.configure?.()) as ServiceDiscoveryMockConfigurator; + + expect(configurator).toBeInstanceOf(ServiceDiscoveryMockConfigurator); + expect(configurator.getServices()).not.toHaveLength(0); + }); +}); + +describe('enableServiceDiscoveryMock', () => { + it('exposes the client on the provider so a runner can spy on it', async () => { + const configurator = new ModulesConfigurator([httpModule, serviceDiscoveryModule]); + enableServiceDiscoveryMock(configurator); + + const instances = (await configurator.initialize()) as unknown as { + serviceDiscovery: { + client: ServiceDiscoveryMockClient; + resolveService: (key: string) => Promise; + }; + }; + const spy = vi + .spyOn(instances.serviceDiscovery.client, 'resolveService') + .mockResolvedValue(createMockService({ key: 'apps', uri: 'http://spied' })); + + expect((await instances.serviceDiscovery.resolveService('apps')).uri).toBe('http://spied'); + expect(spy).toHaveBeenCalledWith('apps'); + + spy.mockRestore(); + + expect((await instances.serviceDiscovery.resolveService('apps')).uri).toBe( + 'https://apps.fusion.test', + ); + }); + + it('replaces an already registered service discovery module', async () => { + const configurator = new ModulesConfigurator([httpModule, serviceDiscoveryModule]); + enableServiceDiscoveryMock(configurator); + + expect((await resolveThroughModule(configurator, 'apps')).uri).toBe('https://apps.fusion.test'); + }); + + it('lets the callback configure the registry through the builder', async () => { + const configurator = new ModulesConfigurator([httpModule, serviceDiscoveryModule]); + enableServiceDiscoveryMock(configurator, (builder) => { + builder.setBaseUri('http://localhost:6669'); + builder.addService({ key: 'my-api' }); + }); + + expect((await resolveThroughModule(configurator, 'my-api')).uri).toBe( + 'http://localhost:6669/my-api', + ); + }); + + it('lets the callback take over resolution with its own client', async () => { + const configurator = new ModulesConfigurator([httpModule, serviceDiscoveryModule]); + enableServiceDiscoveryMock(configurator, (builder) => { + builder.setServiceDiscoveryClient({ + resolveServices: async () => [], + resolveService: async (key) => + createMockService({ key, uri: 'http://localhost:5000/from-callback' }), + }); + }); + + expect((await resolveThroughModule(configurator, 'apps')).uri).toBe( + 'http://localhost:5000/from-callback', + ); + }); +}); + +describe('mockServiceDiscovery', () => { + it('applies options to the registry', async () => { + const configurator = new ModulesConfigurator([httpModule]); + mockServiceDiscovery(configurator, { baseUri: 'http://localhost:4000' }); + + expect((await resolveThroughModule(configurator, 'apps')).uri).toBe( + 'http://localhost:4000/apps', + ); + }); + + it('supports a callback-only form', async () => { + const configurator = new ModulesConfigurator([httpModule]); + mockServiceDiscovery(configurator, (builder) => { + builder.setBaseUri('http://localhost:4000'); + }); + + expect((await resolveThroughModule(configurator, 'apps')).uri).toBe( + 'http://localhost:4000/apps', + ); + }); + + it('supports options plus a callback', async () => { + const configurator = new ModulesConfigurator([httpModule]); + mockServiceDiscovery(configurator, { baseUri: 'http://localhost:4000' }, (builder) => { + builder.addService({ key: 'my-api', uri: 'http://localhost:5000/my-api' }); + }); + + expect((await resolveThroughModule(configurator, 'apps')).uri).toBe( + 'http://localhost:4000/apps', + ); + expect((await resolveThroughModule(configurator, 'my-api')).uri).toBe( + 'http://localhost:5000/my-api', + ); + }); +}); diff --git a/packages/modules/service-discovery/src/mock/ServiceDiscoveryMockClient.ts b/packages/modules/service-discovery/src/mock/ServiceDiscoveryMockClient.ts new file mode 100644 index 0000000000..e740cf8c00 --- /dev/null +++ b/packages/modules/service-discovery/src/mock/ServiceDiscoveryMockClient.ts @@ -0,0 +1,129 @@ +import type { IServiceDiscoveryClient } from '../client'; +import type { Service } from '../types'; + +import { createMockService, type MockService } from './create-mock-service'; +import { defaultServiceDiscoveryMockServices } from './default-service-discovery-mock-services'; + +/** + * The registry a mock service discovery client serves from. + */ +export interface ServiceDiscoveryMockClientOptions { + /** + * Services resolvable in the test. + * + * @remarks + * Replaces {@link defaultServiceDiscoveryMockServices} outright. Omit to serve + * the baseline. Composing a registry — starting from the baseline, adding or + * removing individual services — is the configurator's job, so that there is a + * single place where a registry is assembled. + */ + services?: Iterable; + /** + * Base URI used for services that do not declare their own `uri`. + * + * @remarks + * This is the hook for running against a local mock server — Mockoon, Prism, + * the Fusion dev server — without a service worker intercepting requests. With + * `http://localhost:3000`, `apps` resolves to `http://localhost:3000/apps`, so + * the application performs real HTTP against a real (local) server. + */ + baseUri?: string; + /** + * Resolve any unknown key by synthesising a service, rather than throwing. + * + * @remarks + * Defaults to `true` so a test does not fail merely because the application + * resolved a service the test did not think to declare. Set to `false` to + * assert that only declared services are used. + */ + resolveUnknownServices?: boolean; +} + +/** + * An in-memory stand-in for the service discovery client. + * + * @remarks + * Only the boundary that would contact the service registry is replaced. The + * real `ServiceDiscoveryProvider` runs on top of it unchanged, so client + * creation, scope resolution and HTTP client registration behave as they do in + * production — the test exercises the framework rather than the mock. + * + * The client is immutable: it is constructed from a finished registry. Building + * that registry belongs to + * {@link ServiceDiscoveryMockConfigurator | the configurator}, which is why a + * test rarely constructs this class directly. + * + * Both resolve methods are ordinary methods, so any test runner can spy on them + * with its own tooling — no Fusion-specific mocking API to learn. + * + * @example Resolve every service from a locally running mock server + * ```typescript + * const client = new ServiceDiscoveryMockClient({ baseUri: 'http://localhost:3000' }); + * await client.resolveService('apps'); // uri: 'http://localhost:3000/apps' + * ``` + * + * @example Override a single call, from the test body + * ```typescript + * vi.spyOn(fusion.modules.serviceDiscovery.client, 'resolveService').mockResolvedValue(service); + * ``` + */ +export class ServiceDiscoveryMockClient implements IServiceDiscoveryClient { + readonly #services: Map; + readonly #baseUri?: string; + readonly #resolveUnknown: boolean; + + /** + * Creates an immutable client from the supplied mock registry options. + * + * @param options - Services and resolution behavior for the mock client. + */ + public constructor(options: ServiceDiscoveryMockClientOptions = {}) { + const { + services = defaultServiceDiscoveryMockServices, + baseUri, + resolveUnknownServices = true, + } = options; + + this.#baseUri = baseUri; + this.#resolveUnknown = resolveUnknownServices; + // Materialize the iterable once so every lookup uses the same immutable registry. + this.#services = new Map( + [...services].map((service) => [service.key, createMockService(service, baseUri)]), + ); + } + + /** + * Returns every registered service. + * + * @param _allow_cache - Ignored; the registry is already in memory. + * @returns The resolved services. + */ + public async resolveServices(_allow_cache?: boolean): Promise { + return [...this.#services.values()]; + } + + /** + * Resolves a single service by key. + * + * @param key - Service key, such as `apps`. + * @param _allow_cache - Ignored; the registry is already in memory. + * @returns The resolved service. + * @throws When the key is unknown and unknown services are not allowed. + */ + public async resolveService(key: string, _allow_cache?: boolean): Promise { + const service = this.#services.get(key); + // Return the registered object so explicitly configured service details are preserved. + if (service) { + return service; + } + + // Synthesize undeclared services when the mock is configured to be permissive. + if (this.#resolveUnknown) { + return createMockService({ key }, this.#baseUri); + } + + throw new Error( + `Service "${key}" is not registered. Register it with addService({ key: '${key}' }), or allow unknown services.`, + ); + } +} diff --git a/packages/modules/service-discovery/src/mock/ServiceDiscoveryMockConfigurator.ts b/packages/modules/service-discovery/src/mock/ServiceDiscoveryMockConfigurator.ts new file mode 100644 index 0000000000..c16a404982 --- /dev/null +++ b/packages/modules/service-discovery/src/mock/ServiceDiscoveryMockConfigurator.ts @@ -0,0 +1,209 @@ +import { ServiceDiscoveryConfigurator } from '../configurator'; + +import type { MockService } from './create-mock-service'; +import { defaultServiceDiscoveryMockServices } from './default-service-discovery-mock-services'; +import { + ServiceDiscoveryMockClient, + type ServiceDiscoveryMockClientOptions, +} from './ServiceDiscoveryMockClient'; + +/** + * The real service discovery configurator, backed by an in-memory registry. + * + * @remarks + * Nothing else changes: the same builder API, the same validation and the same + * `ServiceDiscoveryProvider` are used. Only the boundary that would contact the + * service registry is substituted. + * + * The registry is assembled here, on the builder, exactly like any other Fusion + * configuration — and the client is constructed from the finished registry when + * the module builds its config. A test therefore never constructs a client just + * to add a service or point services at a local mock server. + * + * The registry starts from {@link defaultServiceDiscoveryMockServices}, so an + * application boots without declaring anything. + * + * @example Serve every service from a local mock server, plus one extra service + * ```typescript + * enableServiceDiscoveryMock(configurator, (builder) => { + * builder.setBaseUri('http://localhost:6669'); + * builder.addService({ key: 'my-api' }); + * }); + * ``` + * + * @example Take full control of resolution + * ```typescript + * enableServiceDiscoveryMock(configurator, (builder) => { + * builder.setServiceDiscoveryClient(new MyOwnDiscoveryClient()); + * }); + * ``` + */ +export class ServiceDiscoveryMockConfigurator extends ServiceDiscoveryConfigurator { + #services = new Map( + // Index defaults by key so later registrations can replace one service deterministically. + defaultServiceDiscoveryMockServices.map((service) => [service.key, service]), + ); + #baseUri?: string; + #resolveUnknownServices = true; + + /** + * Creates a configurator with the supplied initial mock registry options. + * + * @param options - Initial registry and resolution options. + */ + constructor(options: ServiceDiscoveryMockClientOptions = {}) { + super(); + this.configure(options); + // Deferred so that the client is built from the registry as it ends up, not as it starts + this.setServiceDiscoveryClient(async () => this.createServiceDiscoveryClient()); + } + + /** + * Applies a set of registry options. + * + * @remarks + * Only the properties present are applied, so this composes with whatever was + * configured before. `services` replaces the registry outright; use + * {@link ServiceDiscoveryMockConfigurator.addServices | addServices} to keep + * the baseline. + * + * @param options - Options to apply. + * @returns The builder, for chaining. + */ + public configure(options: ServiceDiscoveryMockClientOptions): this { + // Apply each optional setting independently so omitted options preserve prior builder state. + if (options.baseUri !== undefined) { + this.setBaseUri(options.baseUri); + } + // Replace the registry only when the caller explicitly supplies services. + if (options.services) { + this.setServices(options.services); + } + // Keep the permissive default unless the caller explicitly changes it. + if (options.resolveUnknownServices !== undefined) { + this.setResolveUnknownServices(options.resolveUnknownServices); + } + return this; + } + + /** + * Points every service without an explicit `uri` at the given host. + * + * @remarks + * This is how a locally running mock server — Mockoon, Prism, the Fusion dev + * server — is addressed: the application performs real HTTP against a real + * server, with nothing intercepting the requests. + * + * @param baseUri - Host to resolve services against, such as `http://localhost:6669`. + * @returns The builder, for chaining. + */ + public setBaseUri(baseUri: string | undefined): this { + this.#baseUri = baseUri; + return this; + } + + /** + * Returns the host services without an explicit `uri` resolve against. + * + * @returns The configured base URI, if one was set. + */ + public getBaseUri(): string | undefined { + return this.#baseUri; + } + + /** + * Registers a service, replacing any existing declaration with the same `key`. + * + * @param service - The service to register. Only `key` is required; `uri` and + * `scopes` are derived from it when omitted. + * @returns The builder, for chaining. + * + * @example + * ```typescript + * builder.addService({ key: 'my-api', uri: 'http://localhost:6669/my-api' }); + * ``` + */ + public addService(service: MockService): this { + this.#services.set(service.key, service); + return this; + } + + /** + * Registers several services, replacing existing declarations by `key`. + * + * @param services - The services to register. + * @returns The builder, for chaining. + */ + public addServices(services: Iterable): this { + // Register each item through addService so key replacement stays consistent. + for (const service of services) { + this.addService(service); + } + return this; + } + + /** + * Removes a registered service. + * + * @remarks + * Use together with + * {@link ServiceDiscoveryMockConfigurator.setResolveUnknownServices | setResolveUnknownServices(false)} + * to assert that an application does not reach for a service. + * + * @param key - Key of the service to remove. + * @returns The builder, for chaining. + */ + public removeService(key: string): this { + this.#services.delete(key); + return this; + } + + /** + * Replaces the registry, dropping the baseline services. + * + * @param services - The complete set of services to serve. + * @returns The builder, for chaining. + */ + public setServices(services: Iterable): this { + this.#services.clear(); + return this.addServices(services); + } + + /** + * Returns the services currently registered. + * + * @returns A snapshot of the registered services. + */ + public getServices(): MockService[] { + return [...this.#services.values()]; + } + + /** + * Controls whether unknown keys resolve or throw. + * + * @param resolveUnknownServices - `false` to throw for undeclared services, + * which asserts that the application only uses services the test declared. + * @returns The builder, for chaining. + */ + public setResolveUnknownServices(resolveUnknownServices: boolean): this { + this.#resolveUnknownServices = resolveUnknownServices; + return this; + } + + /** + * Builds a client from the configured registry. + * + * @remarks + * Called when the module assembles its configuration, which is why every + * builder call — whenever it happened — is reflected in the client. + * + * @returns A client serving the configured registry. + */ + protected createServiceDiscoveryClient(): ServiceDiscoveryMockClient { + return new ServiceDiscoveryMockClient({ + services: this.getServices(), + baseUri: this.#baseUri, + resolveUnknownServices: this.#resolveUnknownServices, + }); + } +} diff --git a/packages/modules/service-discovery/src/mock/create-mock-service.ts b/packages/modules/service-discovery/src/mock/create-mock-service.ts new file mode 100644 index 0000000000..11495b9cc8 --- /dev/null +++ b/packages/modules/service-discovery/src/mock/create-mock-service.ts @@ -0,0 +1,79 @@ +import type { Service } from '../types'; + +/** + * A service registration expressed the way a test wants to state it. + * + * @remarks + * Only `key` is required; a URI and scopes are derived from it when omitted, so + * a test can register a service by name alone. + */ +export interface MockService { + /** Lookup key applications resolve the service by, such as `apps` or `people`. */ + key: string; + /** Base URI of the service. Defaults to `https://{key}.fusion.test`. */ + uri?: string; + /** Scopes required for the service. Defaults to `['{key}/.default']`. */ + scopes?: string[]; + /** Human-readable name. Defaults to the key. */ + name?: string; +} + +/** + * Trims trailing slashes so a base URI joins cleanly with a service key. + * + * @param baseUri - Base URI as supplied by the test. + * @returns The base URI without trailing separators. + */ +const normalizeBaseUri = (baseUri: string): string => baseUri.trim().replace(/\/+$/, ''); + +/** + * Resolves the URI a service should be reachable at. + * + * @remarks + * An explicit `uri` always wins, so a single service can be redirected without + * affecting the rest. A `baseUri` points every remaining service at one host, + * which is how a locally running mock server (Mockoon, Prism, a dev server) is + * addressed without intercepting HTTP. + * + * @param service - The service as declared by the test. + * @param baseUri - Optional host all otherwise-undeclared services resolve to. + * @returns The absolute URI for the service. + */ +const makeServiceUri = (service: MockService, baseUri?: string): string => { + // Preserve a service-specific endpoint so it can target a different mock host. + if (service.uri) { + return service.uri; + } + + // Use the shared host when the test wants all otherwise-unspecified services grouped. + if (baseUri) { + return `${normalizeBaseUri(baseUri)}/${service.key}`; + } + + return `https://${service.key}.fusion.test`; +}; + +/** + * Expands a sparse {@link MockService} into a complete {@link Service}. + * + * @param service - The service as declared by the test. + * @param baseUri - Optional host to resolve the service against. + * @returns A service with every field populated deterministically. + * + * @example + * ```typescript + * createMockService({ key: 'apps' }, 'http://localhost:3000'); + * // { key: 'apps', uri: 'http://localhost:3000/apps', scopes: ['apps/.default'], … } + * ``` + */ +export const createMockService = (service: MockService, baseUri?: string): Service => { + const scopes = service.scopes ?? [`${service.key}/.default`]; + return { + key: service.key, + uri: makeServiceUri(service, baseUri), + name: service.name ?? service.key, + scopes, + // Deprecated alias kept in sync so code reading either property agrees + defaultScopes: scopes, + }; +}; diff --git a/packages/modules/service-discovery/src/mock/default-service-discovery-mock-services.ts b/packages/modules/service-discovery/src/mock/default-service-discovery-mock-services.ts new file mode 100644 index 0000000000..22402240d0 --- /dev/null +++ b/packages/modules/service-discovery/src/mock/default-service-discovery-mock-services.ts @@ -0,0 +1,22 @@ +import type { MockService } from './create-mock-service'; + +/** + * Services a Fusion application resolves during a normal start-up. + * + * @remarks + * Exported as the baseline so the mock works with no arguments for the common + * case, and so a test can see — and extend — exactly which services the runtime + * expects to be resolvable. + * + * @example Add a service without restating the baseline + * ```typescript + * new ServiceDiscoveryMockClient({ services: [{ key: 'my-service' }] }); + * ``` + */ +export const defaultServiceDiscoveryMockServices: ReadonlyArray = [ + { key: 'apps' }, + { key: 'people' }, + { key: 'context' }, + { key: 'bookmarks' }, + { key: 'notification' }, +]; diff --git a/packages/modules/service-discovery/src/mock/index.ts b/packages/modules/service-discovery/src/mock/index.ts new file mode 100644 index 0000000000..db78c0e20f --- /dev/null +++ b/packages/modules/service-discovery/src/mock/index.ts @@ -0,0 +1,29 @@ +/** + * Test doubles for the Service Discovery module. + * + * @remarks + * Imported from `@equinor/fusion-framework-module-service-discovery/mock`, so the + * mock ships and versions with the implementation it stands in for. + * + * The mock is injected through the module's own `setServiceDiscoveryClient` + * configuration, so the real configuration builder still runs and validates — only + * the boundary that would contact the service registry is substituted. + * + * This entry point has no dependency on any test runner. + * + * @packageDocumentation + */ + +export { + ServiceDiscoveryMockClient, + type ServiceDiscoveryMockClientOptions, +} from './ServiceDiscoveryMockClient'; +export { ServiceDiscoveryMockConfigurator } from './ServiceDiscoveryMockConfigurator'; +export { createMockService, type MockService } from './create-mock-service'; +export { defaultServiceDiscoveryMockServices } from './default-service-discovery-mock-services'; +export { mockServiceDiscovery } from './mock-service-discovery'; +export { + enableServiceDiscoveryMock, + serviceDiscoveryMockModule, + type ServiceDiscoveryConfigMockFn, +} from './module'; diff --git a/packages/modules/service-discovery/src/mock/mock-service-discovery.ts b/packages/modules/service-discovery/src/mock/mock-service-discovery.ts new file mode 100644 index 0000000000..d0c6d6f603 --- /dev/null +++ b/packages/modules/service-discovery/src/mock/mock-service-discovery.ts @@ -0,0 +1,73 @@ +import type { IModulesConfigurator } from '@equinor/fusion-framework-module'; + +import { enableServiceDiscoveryMock, type ServiceDiscoveryConfigMockFn } from './module'; +import type { ServiceDiscoveryMockClientOptions } from './ServiceDiscoveryMockClient'; + +/** + * Replaces service discovery with an in-memory registry. + * + * @remarks + * Applications resolve service URIs and scopes exactly as they do in production, + * but no service registry is contacted. Resolution is stable across runs. + * + * This is the one-call shorthand over {@link enableServiceDiscoveryMock}; reach + * for the enabler directly when the callback alone is enough. + * + * @param configurator - The configurator to apply the mock to. + * @param optionsOrConfigure - Registry options, or a configuration callback. + * @param configure - Configuration callback when options were supplied. + * + * @example Point one service at a local test server + * ```typescript + * mockServiceDiscovery(configurator, { + * services: [{ key: 'apps', uri: 'http://localhost:6669/apps' }], + * }); + * ``` + * + * @example Point all services at a local mock server + * ```typescript + * mockServiceDiscovery(configurator, { baseUri: 'http://localhost:6669' }); + * ``` + * + * @example Configure the registry through the builder + * ```typescript + * mockServiceDiscovery(configurator, (builder) => { + * builder.setBaseUri('http://localhost:6669'); + * builder.addService({ key: 'my-api' }); + * }); + * ``` + */ +export function mockServiceDiscovery( + // biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types + configurator: IModulesConfigurator, + configure?: ServiceDiscoveryConfigMockFn, +): void; + +export function mockServiceDiscovery( + // biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types + configurator: IModulesConfigurator, + options?: ServiceDiscoveryMockClientOptions, + configure?: ServiceDiscoveryConfigMockFn, +): void; + +/** + * Applies the mock service discovery configuration to a module configurator. + * + * @param configurator - The configurator receiving the mock module. + * @param optionsOrConfigure - Registry options or a configuration callback. + * @param configure - Optional callback used when options are supplied. + */ +export function mockServiceDiscovery( + // biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types + configurator: IModulesConfigurator, + optionsOrConfigure?: ServiceDiscoveryMockClientOptions | ServiceDiscoveryConfigMockFn, + configure?: ServiceDiscoveryConfigMockFn, +): void { + const options = typeof optionsOrConfigure === 'function' ? {} : (optionsOrConfigure ?? {}); + const callback = typeof optionsOrConfigure === 'function' ? optionsOrConfigure : configure; + + enableServiceDiscoveryMock(configurator, (builder, ref) => { + builder.configure(options); + callback?.(builder, ref); + }); +} diff --git a/packages/modules/service-discovery/src/mock/module.ts b/packages/modules/service-discovery/src/mock/module.ts new file mode 100644 index 0000000000..4d52024920 --- /dev/null +++ b/packages/modules/service-discovery/src/mock/module.ts @@ -0,0 +1,56 @@ +import type { IModulesConfigurator } from '@equinor/fusion-framework-module'; + +import { module as serviceDiscoveryModule, type ServiceDiscoveryModule } from '../module'; + +import { ServiceDiscoveryMockConfigurator } from './ServiceDiscoveryMockConfigurator'; + +/** + * The service discovery module with an in-memory registry instead of a live + * connection to the service registry. + * + * @remarks + * Only `configure` differs from the real module, so the provider, the schema and + * the initialization flow stay exactly as they are in production. + */ +export const serviceDiscoveryMockModule: ServiceDiscoveryModule = { + ...serviceDiscoveryModule, + configure: () => new ServiceDiscoveryMockConfigurator(), +}; + +/** + * Configuration callback for {@link enableServiceDiscoveryMock}. + */ +export type ServiceDiscoveryConfigMockFn = ( + configurator: ServiceDiscoveryMockConfigurator, + ref?: TRef, +) => void; + +/** + * Enables service discovery against an in-memory registry, so a test needs no + * network and no service registry. + * + * @remarks + * Registered last, this replaces whichever service discovery 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 compose the registry. + * + * @example Point every service at a local mock server and add one of your own + * ```typescript + * enableServiceDiscoveryMock(configurator, (builder) => { + * builder.setBaseUri('http://localhost:6669'); + * builder.addService({ key: 'my-api' }); + * }); + * ``` + */ +export const enableServiceDiscoveryMock = ( + // biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types + configurator: IModulesConfigurator, + configure?: ServiceDiscoveryConfigMockFn, +): void => { + configurator.addConfig({ module: serviceDiscoveryMockModule, configure } as { + module: ServiceDiscoveryModule; + }); +}; diff --git a/packages/modules/service-discovery/src/module.ts b/packages/modules/service-discovery/src/module.ts index 95c54c9d54..f5b4d932ed 100644 --- a/packages/modules/service-discovery/src/module.ts +++ b/packages/modules/service-discovery/src/module.ts @@ -99,7 +99,7 @@ export const module: ServiceDiscoveryModule = { * ``` */ export const configureServiceDiscovery = ( - callback: (config: ServiceDiscoveryConfigurator) => Promise, + callback: (config: ServiceDiscoveryConfigurator) => void | Promise, ): IModuleConfigurator => ({ module, configure: (config: ServiceDiscoveryConfigurator) => callback(config), @@ -115,8 +115,9 @@ export const configureServiceDiscovery = ( * * @param configurator - The modules configurator to register the module on. * Must already include {@link HttpModule}. - * @param callback - Optional async callback receiving a - * {@link ServiceDiscoveryConfigurator} for advanced setup. + * @param callback - Optional callback receiving a + * {@link ServiceDiscoveryConfigurator} for advanced setup. May be synchronous + * or asynchronous. * * @example * ```typescript @@ -133,7 +134,7 @@ export const configureServiceDiscovery = ( */ export const enableServiceDiscovery = ( configurator: ModulesConfigurator<[HttpModule]>, - callback?: (config: ServiceDiscoveryConfigurator) => Promise, + callback?: (config: ServiceDiscoveryConfigurator) => void | Promise, ): void => { configurator.addConfig(configureServiceDiscovery(callback ?? (() => Promise.resolve()))); }; diff --git a/packages/modules/service-discovery/src/provider.ts b/packages/modules/service-discovery/src/provider.ts index a35395fee0..d11e7bd798 100644 --- a/packages/modules/service-discovery/src/provider.ts +++ b/packages/modules/service-discovery/src/provider.ts @@ -12,6 +12,7 @@ import { version } from './version'; import type { Service } from './types'; import type { ServiceDiscoveryConfig } from './configurator'; +import type { IServiceDiscoveryClient } from './client'; /** * Public API surface of the Service Discovery provider. @@ -88,6 +89,15 @@ export interface IServiceDiscoveryProvider { /** The resolved service discovery configuration. */ readonly config: ServiceDiscoveryConfig; + + /** + * The client this provider resolves services through. + * + * @remarks + * Mirrors `MsalProvider.client`. Exposed so a test can spy on resolution with + * its own test runner without reaching into {@link IServiceDiscoveryProvider.config | config}. + */ + readonly client: IServiceDiscoveryClient; } /** @@ -115,6 +125,11 @@ export class ServiceDiscoveryProvider }); } + /** {@inheritDoc IServiceDiscoveryProvider.client} */ + public get client(): IServiceDiscoveryClient { + return this.config.discoveryClient; + } + /** {@inheritDoc IServiceDiscoveryProvider.resolveServices} */ public resolveServices(): Promise { return this.config.discoveryClient.resolveServices(); diff --git a/packages/modules/service-discovery/vitest.config.ts b/packages/modules/service-discovery/vitest.config.ts new file mode 100644 index 0000000000..f00c6cabd8 --- /dev/null +++ b/packages/modules/service-discovery/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 55f9d6bd21..8652346020 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10756,10 +10756,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.1.2: - resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} - engines: {node: '>=18'} - tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -20381,8 +20377,6 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.1.2: {} - tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -20812,7 +20806,7 @@ snapshots: picomatch: 4.0.5 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.1.2 + tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 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) @@ -20843,7 +20837,7 @@ snapshots: picomatch: 4.0.5 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.1.2 + 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)