Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/module-azure-identity_mock-auth-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@equinor/fusion-framework-module-azure-identity": minor
---

Add `MockAuthProvider`, a configurable `IAuthProvider` test double, exported from a new `/mock` subpath (`@equinor/fusion-framework-module-azure-identity/mock`).

Unlike `token_only` mode's `AuthProviderTokenOnly` — a single fixed token where `login`/`logout` always throw — `MockAuthProvider` actually implements `login`/`logout`: a test can drive the provider from signed-out to signed-in (and back), and control the returned access token and its `expiresOn`, including setting an expiry in the past to exercise a consuming application's own token-refresh logic. No real `@azure/identity` network calls are made.

```typescript
import { enableAuthMock } from '@equinor/fusion-framework-module-azure-identity/mock';

const auth = enableAuthMock(configurator, (auth) => {
auth.setAccount({ username: 'ada@equinor.com', signedOut: true });
});

await auth.login({ request: { scopes: ['User.Read'] } });
const token = await auth.acquireAccessToken({ request: { scopes: ['User.Read'] } });

// simulate an expired token
auth.setExpiresOn(new Date(Date.now() - 1000));
```

`MockAuthProvider` registers as the `'auth'` module's provider exactly like any real implementation — no special-cased wiring in the module itself. This does not change or replace `token_only` mode, which remains the right choice for CI/CD scenarios needing a static token.

Related: equinor/fusion-core-tasks#1665.
25 changes: 25 additions & 0 deletions .changeset/module-msal-node_mock-auth-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@equinor/fusion-framework-module-msal-node": minor
---

Add `MockAuthProvider`, a configurable `IAuthProvider` test double, exported from a new `/mock` subpath (`@equinor/fusion-framework-module-msal-node/mock`).

Unlike `token_only` mode's `AuthTokenProvider` — a single fixed token where `login`/`logout` always throw — `MockAuthProvider` actually implements `login`/`logout`: a test can drive the provider from signed-out to signed-in (and back), and control the returned `AuthenticationResult`'s access token and `expiresOn`, including setting an expiry in the past to exercise a consuming application's own token-refresh logic. No real `@azure/msal-node` network calls are made, and no browser or local callback server is opened.

```typescript
import { enableAuthMock } from '@equinor/fusion-framework-module-msal-node/mock';

const auth = enableAuthMock(configurator, (auth) => {
auth.setAccount({ username: 'ada@equinor.com', signedOut: true });
});

await auth.login({ request: { scopes: ['User.Read'] } });
const token = await auth.acquireAccessToken({ request: { scopes: ['User.Read'] } });

// simulate an expired token
auth.setExpiresOn(new Date(Date.now() - 1000));
```

`MockAuthProvider` registers as the `'auth'` module's provider exactly like any real implementation — no special-cased wiring in the module itself. This does not change or replace `token_only` mode, which remains the right choice for CI/CD scenarios needing a static token.

Related: equinor/fusion-core-tasks#1665.
35 changes: 35 additions & 0 deletions packages/modules/azure-identity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,32 @@ await framework.auth.logout();

Returns a pre-obtained token string. `login()` and `logout()` throw.

## Testing

`token_only` mode already avoids real Azure AD traffic, but it is rigid: a single
fixed token, no expiry simulation, and `login`/`logout` always throw. For tests
that need to exercise sign-in, sign-out, or token-refresh/expiry logic, use
`MockAuthProvider` from the `/mock` subpath instead:

```typescript
import { enableAuthMock } from '@equinor/fusion-framework-module-azure-identity/mock';

// seed the identity/state up front; omit the callback for a default, already-signed-in identity
const auth = enableAuthMock(configurator, (auth) => {
auth.setAccount({ username: 'ada@equinor.com', signedOut: true });
});

// drive login/logout and inspect the resulting tokens directly
await auth.login({ request: { scopes: ['User.Read'] } });
const token = await auth.acquireAccessToken({ request: { scopes: ['User.Read'] } });

// simulate an expired token, to exercise a consuming application's refresh path
auth.setExpiresOn(new Date(Date.now() - 1000));
```

`MockAuthProvider` registers exactly like any other `IAuthProvider` implementation
— no special-cased wiring — and makes no real `@azure/identity` network calls.

## Token cache persistence

The module registers `cachePersistencePlugin` from `@azure/identity-cache-persistence` at load time, enabling encrypted OS-level token caching:
Expand Down Expand Up @@ -167,3 +193,12 @@ enableAzureIdentityAuth(configurator, (builder) => {
| `IAuthProvider` | Auth provider interface |
| `AzureIdentityModule` | Module type for generic parameters |
| `azureIdentityModule` | Raw module definition |

### `/mock` subpath

| Export | Description |
|---|---|
| `MockAuthProvider` | Configurable `IAuthProvider` test double — `login`/`logout` work, token and expiry are settable |
| `MockAuthProviderOptions` | Constructor options for `MockAuthProvider` |
| `enableAuthMock` | Registers a `MockAuthProvider` as the `'auth'` module and returns it |
| `createAuthMockModule` | Builds the mock module descriptor for a given `MockAuthProvider` |
18 changes: 17 additions & 1 deletion packages/modules/azure-identity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,25 @@
".": {
"import": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts"
},
"./mock": {
"import": "./dist/esm/mock/index.js",
"types": "./dist/types/mock/index.d.ts"
}
},
"typesVersions": {
"*": {
".": [
"dist/types/index.d.ts"
],
"mock": [
"dist/types/mock/index.d.ts"
]
}
},
"scripts": {
"build": "tsc -b",
"test": "vitest run",
"prepack": "pnpm build"
},
"keywords": [
Expand Down Expand Up @@ -43,6 +58,7 @@
"@azure/msal-node-extensions": "^5.1.4"
},
"devDependencies": {
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import { ModulesConfigurator } from '@equinor/fusion-framework-module';

import { module as realModule } from '../../module';
import type { IAuthProvider } from '../../AuthProvider.interface';
import { createAuthMockModule, enableAuthMock, MockAuthProvider } from '../../mock';

describe('MockAuthProvider', () => {
it('is signed in with a default identity out of the box', async () => {
const auth = new MockAuthProvider();

const token = await auth.acquireToken({ request: { scopes: ['User.Read'] } });

expect(token?.accessToken).toBeDefined();
expect(token?.expiresOn).toBeNull();
});

it('throws from acquireToken/acquireAccessToken when constructed signedOut', async () => {
const auth = new MockAuthProvider({ signedOut: true });

await expect(auth.acquireToken({ request: { scopes: [] } })).rejects.toThrow(/not signed in/);
await expect(auth.acquireAccessToken({ request: { scopes: [] } })).rejects.toThrow(
/not signed in/,
);
});

it('signs in on login, resolving the configured identity', async () => {
const auth = new MockAuthProvider({
signedOut: true,
account: { username: 'ada@equinor.com' },
});

const record = await auth.login({ request: { scopes: ['User.Read'] } });

expect(record.username).toBe('ada@equinor.com');
await expect(
auth.acquireAccessToken({ request: { scopes: ['User.Read'] } }),
).resolves.toBeDefined();
});

it('signs out on logout, without discarding the identity for a later login', async () => {
const auth = new MockAuthProvider();

await auth.logout();
await expect(auth.acquireToken({ request: { scopes: [] } })).rejects.toThrow(/not signed in/);

const record = await auth.login({ request: { scopes: [] } });
expect(record.username).toBe('test.user@equinor.com');
});

it('setAccount merges identity fields and can flip the signed-in state', async () => {
const auth = new MockAuthProvider();

auth.setAccount({ username: 'ada@equinor.com', signedOut: true });

await expect(auth.acquireToken({ request: { scopes: [] } })).rejects.toThrow(/not signed in/);

const record = await auth.login({ request: { scopes: [] } });
expect(record.username).toBe('ada@equinor.com');
});

it('setAccessToken replaces the token returned by acquireToken/acquireAccessToken', async () => {
const auth = new MockAuthProvider();

auth.setAccessToken('a-different-token');

const token = await auth.acquireToken({ request: { scopes: [] } });
expect(token?.accessToken).toBe('a-different-token');
await expect(auth.acquireAccessToken({ request: { scopes: [] } })).resolves.toBe(
'a-different-token',
);
});

it('setExpiresOn lets a test simulate an already-expired token', async () => {
const auth = new MockAuthProvider();
const past = new Date(Date.now() - 1000);

auth.setExpiresOn(past);

const token = await auth.acquireToken({ request: { scopes: [] } });
expect(token?.expiresOn).toEqual(past);
expect(token?.expiresOn?.getTime()).toBeLessThan(Date.now());
});
});

describe('createAuthMockModule', () => {
it('shares the real module name, so it is a drop-in replacement for the "auth" slot', () => {
const mod = createAuthMockModule();

expect(mod.name).toBe(realModule.name);
});

it('registers the given MockAuthProvider as the module config, unchanged', () => {
const auth = new MockAuthProvider({ account: { username: 'ada@equinor.com' } });
const mod = createAuthMockModule(auth);

expect(mod.configure?.()).toBe(auth);
});
});

describe('enableAuthMock', () => {
it('initializes the "auth" module as the MockAuthProvider it registered', async () => {
const configurator = new ModulesConfigurator([]);
const auth = enableAuthMock(configurator);

const instances = await configurator.initialize();

expect((instances as unknown as { auth: IAuthProvider }).auth).toBe(auth);
});

it('replaces an auth module that is already registered', async () => {
// A FrameworkConfigurator pre-registers the real auth module, so the mock is
// only useful if registering it afterwards wins
const configurator = new ModulesConfigurator([realModule]);
enableAuthMock(configurator, (auth) => auth.setAccount({ username: 'ada@equinor.com' }));

const instances = await configurator.initialize();
const record = await (instances as unknown as { auth: IAuthProvider }).auth.login({
request: { scopes: [] },
});

expect(record.username).toBe('ada@equinor.com');
});
});
Loading
Loading